version 2 initial commit

This commit is contained in:
Xu Lingrui 2026-06-02 16:28:29 +08:00
commit c817a02c50
No known key found for this signature in database
1268 changed files with 295344 additions and 0 deletions

93
.gitignore vendored Normal file
View file

@ -0,0 +1,93 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# OS files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
Desktop.ini
# IDE files
.vscode/
.idea/
.pytest_cache/
.ruff_cache/
# Development-only repository folders
docs/
tests/
scripts/
# Local agent/project memory
OPENSPACE.md
OPENSPACE.local.md
# Distribution / packaging
dist/
build/
*.egg-info/
*.egg
openspace/packaged/
# Environment files (ignore .env but keep .env.example)
.env
!.env.example
# MCP config files
openspace/config/config_mcp.json
# Communication config files
openspace/config/config_communication.json
# Logs
logs/
# GDPVal reference file cache (pre-downloaded from HuggingFace)
benchmarks/gdpval/ref_cache/
# Embedding cache - root level fully ignored
/.openspace/
# GDPVal benchmark cache
benchmarks/gdpval/.openspace/*
!benchmarks/gdpval/.openspace/*.db
# Embedding cache
embedding_cache/
tool_quality/
# MCP tool cache
mcp_tool_cache.json
mcp_tool_cache_sanitized.json
# Config files
openspace/config/config_dev.json
# LLM keys
openspace/llm/remote_client/
# Local server temp files
openspace/local_server/temp/
# Example app local state and build artifacts
examples/*/.openspace/*
!examples/*/.openspace/*.db
examples/*/dist/
examples/*/node_modules/
# Skills
openspace/skills/*
!openspace/skills/README.md
!openspace/skills/remember/
!openspace/skills/remember/SKILL.md
!openspace/skills/remember/.skill_id
# Frontend dependencies
node_modules/
# App local dependency links
apps/*/node_modules/

5
COMMUNICATION.md Normal file
View file

@ -0,0 +1,5 @@
We provide QR codes for joining the HKUDS discussion groups on **WeChat** and **Feishu**.
You can join by scanning the QR codes below:
<img src="https://github.com/HKUDS/.github/blob/main/profile/QR.png" alt="WeChat QR Code" width="400"/>

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 ✨Data Intelligence Lab@HKU✨
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

17
MANIFEST.in Normal file
View file

@ -0,0 +1,17 @@
prune tests
prune docs
prune scripts
recursive-exclude openspace/packaged/tui/__tests__ *
recursive-exclude openspace/packaged/tui */__tests__/*
recursive-exclude openspace/packaged/dashboard/__tests__ *
recursive-exclude openspace/packaged/dashboard */__tests__/*
recursive-exclude openspace/packaged/tui *.test.*
recursive-exclude openspace/packaged/tui *.spec.*
recursive-exclude openspace/packaged/dashboard *.test.*
recursive-exclude openspace/packaged/dashboard *.spec.*
exclude openspace/packaged/tui/node_modules/@types/node/test.d.ts
global-exclude __pycache__
global-exclude *.py[cod]
global-exclude .DS_Store

672
README.md Normal file
View file

@ -0,0 +1,672 @@
<div align="center">
<picture>
<img src="assets/logo.png" width="320px" style="border: none; box-shadow: none;" alt="OpenSpace Logo">
</picture>
## ✨ OpenSpace: Make Your Agents: Smarter, Low-Cost, Self-Evolving ✨
| 🔋 **46% Fewer Tokens** | **💰 $11K earned in 6 Hours** | 🧬 **Self-Evolving Skills** | 🌐 **Agents Experience Sharing** |
[![Agents](https://img.shields.io/badge/Agents-Claude_Code%20%7C%20Codex%20%7C%20OpenClaw%20%7C%20nanobot%20%7C%20...-99C9BF.svg)](https://modelcontextprotocol.io/)
[![Python](https://img.shields.io/badge/Python-3.12+-FCE7D6.svg)](https://www.python.org/)
[![License](https://img.shields.io/badge/License-MIT-C1E5F5.svg)](https://opensource.org/licenses/MIT/)
[![Feishu](https://img.shields.io/badge/Feishu-Group-E9DBFC?style=flat&logo=larksuite&logoColor=white)](./COMMUNICATION.md)
[![WeChat](https://img.shields.io/badge/WeChat-Group-C5EAB4?style=flat&logo=wechat&logoColor=white)](./COMMUNICATION.md)
[![中文文档](https://img.shields.io/badge/文档-中文版-F5C6C6?style=flat)](./README_CN.md)
**One Command to Evolve All Your AI Agents**: OpenClaw, nanobot, Claude Code, Codex, Cursor and etc.
<img src="assets/cli-typing.gif" width="500px" alt="openspace --query your task">
</div>
---
## 📢 News
- **2026-04-09** 💬 Multi-channel **communication gateway**. OpenSpace can now receive and respond to messages from external platforms. Ships with **WhatsApp** (Baileys bridge + QR auth) and **Feishu** (HTTP webhook) adapters, session management, attachment caching, and allowlist-based access control. See [`openspace/config/README.md`](openspace/config/README.md) for setup.
- **2026-04-07** 🌐 OpenSpace MCP now supports standalone **SSE** and **streamable HTTP** startup, making it easier for remote hosts to connect over HTTP instead of stdio and bypass stdio-bound MCP server timeout bottlenecks. See the [host integration guide](openspace/host_skills/README.md) for setup details.
- **2026-04-06** 🛠️ Fixed multiple runtime issues across grounding, MCP serving, skill evolution, and persistence, improving execution stability and recovery in long-running workflows.
- **2026-04-05** 🧭 Cleaned up LLM credential resolution: centralized `.env` loading, improved host config auto-detection, and made provider-native env handling more consistent.
- **2026-04-03** 🚀 Released **v0.1.0** — Skill quality monitoring: structural patterns extracted from high-quality skills now evaluate every new submission daily. Faster, more relevant cloud search. Production-grade vertical skill clusters emerging organically from the community. Frontend now supports Chinese (zh) i18n.
- **2026-04-02** ⚡ Cloud search upgraded for higher relevance and lower latency.
- **2026-03-31** 🛡️ Security hardening: hardened zip extraction and `import_skill` against path traversal. CLI now respects `OPENSPACE_MODEL` and `OPENSPACE_LLM_*` env vars; MiniMax compatibility; workflow ID collision fixes.
- **2026-03-29** 🔒 Pinned litellm to <1.82.7 to avoid PYSEC-2026-2 supply-chain attack.
- **2026-03-28** 🔧 Idempotent skill registration — `register_skill_dir` now returns existing `SkillMeta` for already-registered skills. Updated OpenClaw setup docs.
- **2026-03-27** 🪟 Fixed stdio deadlock on Windows; improved evolver confirmation parsing with stem-style keyword matching.
- **2026-03-26** 🌱 Dynamic skill directory re-scanning on each call, lightweight local skill search, and streamlined documentation.
- **2026-03-25** 🎉 OpenSpace is now open source!
---
## The Problem with Today's AI Agents
Today's AI agents — [OpenClaw](https://github.com/openclaw/openclaw), [nanobot](https://github.com/HKUDS/nanobot), [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Codex](https://github.com/openai/codex), [Cursor](https://cursor.com), etc. — are powerful, but they have a critical weakness: they never **Learn**, **Adapt**, and **Evolve** from real-world experience — let alone **Share** with each other.
- **❌ Massive Token Waste** - How to reuse successful task patterns instead of reasoning from scratch and burning tokens every time?
- **❌ Repeated Costly Failures** - How to share solutions across agents instead of repeating the same costly exploration and mistakes?
- **❌ Poor and Unreliable Skills** - How to maintain skill reliability as tools and APIs evolve — while ensuring community-contributed skills meet rigorous quality standards?
## 🎯 What is OpenSpace?
**🚀 🚀 The self-evolving engine where every task makes every agent smarter and more cost-efficient.**
https://github.com/user-attachments/assets/c50f70ab-f6db-47bf-9498-3210c0f0abae
OpenSpace plugs into any agent as skills and evolves it with three superpowers:
### 🧬 Self-Evolution
Skills that learn and improve themselves automatically
- ✅ **AUTO-FIX** — When a skill breaks, it fixes itself instantly
- ✅ **AUTO-IMPROVE** — Successful patterns become better skill versions
- ✅ **AUTO-LEARN** — Captures winning workflows from actual usage
- ✅ **Quality monitoring** — Tracks skill performance, error rates, and execution success across all tasks.
**Skills that continuously evolve — turning every failure into improvement, every success into optimization.**
### 🌐 Collective Agent Intelligence
Turn individual agents into a shared brain
- ✅ **Shared evolution**: One agent's improvement becomes every agent's upgrade
- ✅ **Network effects**: More agents → richer data → faster evolution for every agent
- ✅ **Easy sharing** — Upload and download evolved skills with one simple command
- ✅ **Access control** — Choose public, private, or team-only access for each skill
**One agent learns, all agents benefit — collective intelligence at scale.**
### 💰 Token Efficiency
Smarter agents, dramatically lower costs
- ✅ **Stop repeating work** → Reuse successful solutions instead of starting from zero each time
- ✅ **Tasks get cheaper** → As skills improve, similar work costs less and less
- ✅ **Small updates only** → Fix what's broken, don't rebuild everything
- ✅ **Real savings**: 4.2× better performance with 46% fewer tokens on real-world tasks, delivering measurable economic value. ([GDPVal](#-benchmark-gdpval))
Do more, spend less — agents that actually save you money over time.
---
### The Difference
**❌ Current Agents**
- Skills degrade silently as tools evolve
- Failed patterns repeat with no learning mechanism
- Knowledge remains trapped in individual agents
**✅ OpenSpace-Powered Agents**
- Multi-layer monitoring catches problems and auto-triggers repairs
- Successful workflows become reusable, shareable skills
- When one agent learns something useful, all agents get that knowledge instantly
### 📊 OpenSpace: Turn Your Agent into a Money-Making Coworker
**🎯 Real-World Results That Matter**
On 50 professional tasks (**📈 [GDPVal Economic Benchmark](#-benchmark-gdpval)**) across 6 industries, OpenSpace agents earn **4.2× more money** than baseline ([ClawWork](https://github.com/HKUDS/ClawWork)) agents using the same backbone LLM (Qwen 3.5-Plus). While cutting 46% of costly tokens through skill evolution.
<div align="center">
<img src="assets/benchmark_kpi.png" width="100%" alt="GDPVal Benchmark — Key Results" />
</div>
**💼 These Aren't Toy Problems**
- Building payroll calculators from complex union contracts
- Preparing tax returns from 15 scattered PDF documents
- Drafting legal memoranda on California privacy regulations
- Creating compliance forms and engineering specifications
**📈 Consistent Wins Across All Fields**
- Compliance work: +18.5% higher earnings
- Engineering projects: +8.7% better performance
- Professional documents: 56% fewer tokens needed
- Every category improved — no exceptions
<div align="center">
<img src="assets/benchmark_task_showcase.png" width="100%" alt="GDPVal Benchmark — Task Showcase by Category" />
</div>
**OpenSpace doesn't just make agents smarter** — it makes them economically viable. Real work, real money, measurable results.
## Use Case for Autonomous System Development with OpenSpace
**🖥️ [My Daily Monitor](examples/my-daily-monitor/README.md)** — OpenSpace empowers your agent to complete large-scale system development. This personal behavior monitoring system with 20+ live dashboard panels lives in [`examples/my-daily-monitor`](examples/my-daily-monitor) and was built entirely by the agent — 60+ skills evolved from scratch through OpenSpace, demonstrating autonomous end-to-end software development capabilities.
<div align="center">
<img src="assets/my_daily_monitor_dark.png" width="100%" alt="My Daily Monitor Dark Mode" />
</div>
---
## 📋 Table of Contents
- [⚡ Quick Start](#-quick-start)
- [🤖 Path A: For Your Agent](#-path-a-for-your-agent)
- [👤 Path B: As Your Co-Worker](#-path-b-as-your-co-worker)
- [📊 Local Dashboard](#-local-dashboard)
- [📈 Benchmark: GDPVal](#-benchmark-gdpval)
- [📊 Showcase: My Daily Monitor](#-showcase-my-daily-monitor)
- [🏗️ Framework](#-framework)
- [🧬 Self-Evolution Engine](#-self-evolution-engine)
- [🌐 Cloud Skill Community](#-cloud-skill-community)
- [🔧 Advanced Configuration](#-advanced-configuration)
- [📖 Code Structure](#-code-structure)
- [🧪 Testing Layout](#-testing-layout)
- [🤝 Contribute & Roadmap](#-contribute--roadmap)
- [🔗 Related Projects](#-related-projects)
---
## ⚡ Quick Start
🌐 **Just want to explore?** Browse community skills, evolution lineage at **[open-space.cloud](https://open-space.cloud)** — no installation needed.
```bash
git clone https://github.com/HKUDS/OpenSpace.git && cd OpenSpace
pip install -e .
openspace-mcp --help # verify installation
```
> [!TIP]
> **Slow clone?** The `assets/` folder (~50 MB of images) makes the default clone large. Use this lightweight alternative to skip it:
> ```bash
> git clone --filter=blob:none --sparse https://github.com/HKUDS/OpenSpace.git
> cd OpenSpace
> git sparse-checkout set '/*' '!assets/'
> pip install -e .
> ```
**Choose your path:**
- **[Path A](#-path-a-for-your-agent)** — Plug OpenSpace into your agent
- **[Path B](#-path-b-as-your-co-worker)** — Use OpenSpace directly as your AI co-worker
### 🤖 Path A: For Your Agent
Works with any host that can launch an MCP server and read skills (`SKILL.md`). OpenSpace ships host helpers for OpenClaw and nanobot, and can be wired manually from Claude Code, Codex, Cursor, or other MCP-capable agents.
**① Add OpenSpace to your agent's MCP config:**
```json
{
"mcpServers": {
"openspace": {
"command": "openspace-mcp",
"toolTimeout": 600,
"env": {
"OPENSPACE_HOST_SKILL_DIRS": "/path/to/your/agent/skills",
"OPENSPACE_WORKSPACE": "/path/to/OpenSpace",
"OPENSPACE_CLOUD_MODE": "live",
"OPENSPACE_CLOUD_API_KEY": "sk-xxx (optional, for cloud)"
}
}
}
}
```
> [!TIP]
> Credentials (API key, model) are auto-detected from nanobot and OpenClaw configs. Other hosts should set `OPENSPACE_LLM_API_KEY` / `OPENSPACE_MODEL`, or rely on `openspace/.env`.
> [!NOTE]
> OpenSpace supports 3 launch modes:
> - **stdio**: keep `command: "openspace-mcp"` in the host config.
> - **SSE**: start `openspace-mcp --transport sse --host 127.0.0.1 --port 8080`.
> - **streamable HTTP**: start `openspace-mcp --transport streamable-http --host 127.0.0.1 --port 8081`.
>
> Common remote endpoints:
> - SSE endpoint: `http://127.0.0.1:8080/sse`
> - streamable HTTP endpoint: `http://127.0.0.1:8081/mcp`
>
> `stdio` is the simplest option. HTTP modes keep OpenSpace as a standalone server, but **host-specific registration syntax** and **host-side timeouts** still apply.
**② Copy skills** into your agent's skills directory:
```bash
cp -r OpenSpace/openspace/host_skills/delegate-task/ /path/to/your/agent/skills/
cp -r OpenSpace/openspace/host_skills/skill-discovery/ /path/to/your/agent/skills/
```
Done. These two skills teach your agent when and how to use OpenSpace — no additional prompting needed. Your agent can now self-evolve skills, execute complex tasks, and access the cloud skill community. You can also add your own custom skills — see [`openspace/skills/README.md`](openspace/skills/README.md).
> [!NOTE]
> **Cloud community (optional):** Run `openspace-cloud-auth bootstrap-agent-key --email you@example.com --agent-name openspace-local-agent` to provision an owner-scoped cloud agent key. The command stores `OPENSPACE_CLOUD_MODE=live` and `OPENSPACE_CLOUD_API_KEY` locally without printing the raw key. Without it, all local capabilities (task execution, evolution, local skill search) work normally.
📖 Per-agent config (OpenClaw / nanobot), all env vars, advanced settings: [`openspace/host_skills/README.md`](openspace/host_skills/README.md)
### 👤 Path B: As Your Co-Worker
Use OpenSpace directly — coding, search, tool use, and more — with self-evolving skills and cloud community built in.
> [!NOTE]
> Create a `.env` file with your LLM API key. For cloud community access, provision the agent key with `openspace-cloud-auth bootstrap-agent-key` (refer to [`openspace/.env.example`](openspace/.env.example)).
```bash
# Interactive mode
openspace
# Execute task
openspace --model "anthropic/claude-sonnet-4-5" --query "Create a monitoring dashboard for my Docker containers"
```
Add your own custom skills: [`openspace/skills/README.md`](openspace/skills/README.md).
**Cloud CLI** — manage skills from the command line:
```bash
openspace-download-skill <skill_id> # download a skill from the cloud
openspace-upload-skill /path/to/skill/dir # upload a skill to the cloud
```
<details>
<summary><b>Python API</b></summary>
```python
import asyncio
from openspace import OpenSpace
from openspace.runtime import ExecutionRequest
async def main():
async with OpenSpace() as cs:
result = await cs.execute(
ExecutionRequest(
prompt="Analyze GitHub trending repos and create a report",
)
)
print(result.text)
for skill in result.evolved_skills:
print(f" Evolved: {skill['name']} ({skill['origin']})")
asyncio.run(main())
```
</details>
### 📊 Local Dashboard
See how your skills evolve — browse skills, track lineage, compare diffs.
> Requires **Node.js ≥ 20**.
```bash
# Terminal 1. Start backend API
openspace-dashboard --port 7788
# Terminal 2: Start frontend dev server
cd apps/dashboard
npm install # only needed once
npm run dev
```
📖 **Frontend setup guide**: [`apps/dashboard/README.md`](apps/dashboard/README.md)
<div align="center">
<table>
<tr>
<td width="50%"><img src="assets/frontend_1.gif" width="100%" alt="Skill Classes" /></td>
<td width="50%"><img src="assets/frontend_2.gif" width="100%" alt="Cloud Skill Records" /></td>
</tr>
<tr>
<td align="center"><sub>Skill Classes — Browse, Search & Sort</sub></td>
<td align="center"><sub>Cloud — Browse & Discover Skill Records</sub></td>
</tr>
<tr>
<td width="50%"><img src="assets/frontend_3.gif" width="100%" alt="Version Lineage" /></td>
<td width="50%"><img src="assets/frontend_4.gif" width="100%" alt="Workflow Sessions" /></td>
</tr>
<tr>
<td align="center"><sub>Version Lineage — Skill Evolution Graph</sub></td>
<td align="center"><sub>Workflow Sessions — Execution History & Metrics</sub></td>
</tr>
</table>
</div>
---
## 📈 Benchmark: GDPVal
We evaluate OpenSpace on [GDPVal](https://huggingface.co/datasets/openai/gdpval) — 220 real-world professional tasks spanning 44 occupations — using the [ClawWork](https://github.com/HKUDS/ClawWork) evaluation protocol with identical productivity tools and LLM-based scoring. Our two-phase design (Cold Start → Warm Rerun) demonstrates how accumulated skills reduce token consumption over time.
Fair Benchmark: OpenSpace uses Qwen 3.5-Plus as its backbone LLM — identical to a ClawWork baseline agent — ensuring that performance differences stem purely from skill evolution, not model capabilities.
Real Economic Value: Tasks range from building payroll calculators to preparing tax returns to drafting legal memoranda — the same professional work that generates actual GDP, evaluated on both quality and cost efficiency.
<div align="center">
<img src="assets/benchmark_income.png" width="100%" alt="GDPVal Benchmark — Income Comparison" />
</div>
- **4.2× Higher Income** vs ClawWork with the same backbone LLM (Qwen 3.5-Plus)
- **72.8% Value Capture** — $11,484 earned out of $15,764 task value, outperforming all agents
- **70.8% Average Quality** — +30pp above the best ClawWork agent (40.8%)
**45.9% Token Usage** in Phase 2 vs Phase 1 — better results with dramatically lower costs
<div align="center">
<img src="assets/benchmark_quality_tokens.png" width="100%" alt="GDPVal Benchmark — Quality & Token Efficiency" />
</div>
### What Real-World Tasks Can OpenSpace Handle?
The 50 GDPVal tasks span 6 real-world work categories.
- **Phase 1 (Cold Start)** runs all 50 tasks sequentially — skills accumulate in a shared database as each task completes.
- **Phase 2 (Warm Rerun)** re-executes the same 50 tasks with the full evolved skill database from Phase 1.
Income Capture = actual payment earned ÷ maximum possible task value
<div align="center">
<img src="assets/benchmark_task_showcase.png" width="100%" alt="GDPVal Benchmark — Task Showcase by Category" />
</div>
## 🎯 Where Evolution Delivers Maximum Impact — And Why:
| Category | Income Δ | Token Δ | Why |
|---|---|---|---|
| **📝 Documents & Correspondence** (7) | 71→74% (+3.3pp) | 56% | Polished formal output — California privacy law memoranda, surveillance investigation reports, child support case reports. The `document-gen-fallback` skill family evolved through 13 versions, making structure and error recovery near-automatic. |
| **📋 Compliance & Form** (11) | 51→70% (+18.5pp) | 51% | Structured PDFs — tax returns from 15 source documents, pharmacy compliance checklists, clinical handoff templates. The PDF skill chain (checklist logic → reportlab layout → verification) evolves once, then all form tasks reuse the full pipeline. |
| **🎬 Media Production** (3) | 53→58% (+5.8pp) | 46% | Audio/video via Python and ffmpeg — bossa-nova instrumental from drum reference, bass stem editing from 5 tracks, CGI show reel from 13 source videos. Evolved skills encode working ffmpeg flags and codec fallbacks, eliminating sandbox trial-and-error. |
| **🛠️ Engineering** (4) | 70→78% (+8.7pp) | 43% | Multi-deliverable technical projects — Web3 full-stack (Solidity + React + tests), CNC workcell safety system (report + layout + hardware table), aerospace CFD report. Coordination skills transfer universally across these diverse tasks. |
| **📊 Spreadsheets** (15) | 63→70% (+7.3pp) | 37% | Functional .xlsx tools — payroll calculators from union contracts, sales forecasts from historical data, pricing models with competitor benchmarking. Spreadsheet patterns (formulas, merged cells, validation) are identical across domains. |
| **📈 Strategy & Analysis** (10) | 88→89% (+1.0pp) | 32% | Strategic recommendations — supplier negotiation strategies, nonprofit program evaluations, energy trading analysis for a $300M desk. Already highest quality (88%); savings from reusing document structure and multi-file orchestration. |
### What Did Evolution Produce? (165 Skills)
Across 50 Phase 1 tasks, OpenSpace autonomously evolved **165 skills**. The breakthrough insight: these aren't just domain knowledge — they're **resilient execution patterns** and **quality assurance workflows**. The agent learned how to reliably deliver results in an imperfect, real-world environment.
**Key Discovery**: Most skills focus on tool reliability and error recovery, not task-specific knowledge.
<div align="center">
<img src="assets/benchmark_skill_taxonomy.png" width="100%" alt="GDPVal Benchmark — Evolved Skill Taxonomy" />
</div>
| Purpose | Count | What It Teaches the Agent |
|---|---|---|
| **File Format I/O** | 44 | PDF extraction fallbacks, DOCX parsing, Excel merged-cell handling, PPTX creation. 32/44 *captured* from real failures — each one is a production bug solved. |
| **Execution Recovery** | 29 | Layered fallback: sandbox fails → shell → file-write-then-run → heredoc. 28/29 *captured* from actual crashes. The foundation that makes everything else reliable. |
| **Document Generation** | 26 | End-to-end doc pipeline. `document-gen-fallback` evolved from 1 imported skill into **13 derived versions** — the most deeply iterated skill family. |
| **Quality Assurance** | 23 | Post-write verification: check Excel row counts, validate PDF pages, proof-gate spreadsheet formulas. Why P2 quality improves — the agent *verifies*, not just produces. |
| **Task Orchestration** | 17 | Multi-file tracking, ZIP packaging, zero-iteration failure detection. Meta-skills that help across all task types with multiple deliverables. |
| **Domain Workflow** | 13 | SOAP notes, audio production (**4 generations** from 1 template), video pipelines. Small count but deep evolution within each domain. |
| **Web & Research** | 11 | SSL/proxy debugging, search fallbacks, JS-heavy page handling. Includes 2 *fixed* skills — web access is inherently unstable. |
**Reproduce experiments, analysis tools, and results**: [`benchmarks/gdpval/README.md`](benchmarks/gdpval/README.md)
---
## 📊 Showcase: My Daily Monitor
> **Zero human code was written.** 60+ skills evolved from scratch to build a fully working live dashboard.
**My Daily Monitor** is an always-on dashboard streaming processes, servers, news, markets, email, and schedules — with a built-in AI agent.
<div align="center">
<img src="assets/my_daily_monitor_light.png" width="90%" alt="My Daily Monitor Light Mode" />
</div>
### How OpenSpace Built It (From Zero)
| Phase | What Happened | Skills |
|-------|--------------|--------|
| 🌱 **Seed** | Analyzed open-source [WorldMonitor](https://github.com/koala73/worldmonitor), extracted reference patterns | 6 initial skills |
| 🏗️ **Scaffold** | Generated project structure, Vite config, TypeScript setup | +8 skills |
| 🎨 **Build** | Created 20+ panels with data services, API routes, grid layout | +25 skills |
| 🔧 **Fix** | Auto-repaired broken TypeScript, API mismatches, CSS conflicts | +12 FIX evolutions |
| 🧬 **Evolve** | Derived enhanced patterns, merged complementary skills | +15 DERIVED skills |
| 📦 **Capture** | Extracted reusable patterns from successful executions | +8 CAPTURED skills |
### 📈 Skill Evolution Graph
<div align="center">
<img src="assets/my_daily_monitor_evograph.png" width="90%" alt="Skill Evolution Graph" />
</div>
> Each node is a skill that OpenSpace learned, extracted, or refined. The full evolution history is open-sourced in [`examples/my-daily-monitor/.openspace/openspace.db`](examples/my-daily-monitor/.openspace/openspace.db), and the generated app source lives in [`examples/my-daily-monitor`](examples/my-daily-monitor) — load the SQLite DB in any browser to explore lineage, diffs, and quality metrics.
**Full details**: [`examples/my-daily-monitor/README.md`](examples/my-daily-monitor/README.md)
---
## 🏗️ OpenSpace's Framework
<div align="center">
<img src="assets/framework.png" width="90%" alt="OpenSpace Framework" />
</div>
### 🧬 Self-Evolution Engine
The core of OpenSpace. Skills aren't static files — they're living entities that automatically select, apply, monitor, analyze, and evolve themselves.
#### 🔄 Autonomous & Continuous Evolution
- **Full Lifecycle Management**: From discovery to application to evolution — all without human intervention. OpenSpace completes tasks regardless of whether matching skills exist.
**Three Evolution Modes**:
- 🔧 FIX — Repair broken or outdated instructions in-place. Same skill, new version.
- 🚀 DERIVED — Create enhanced or specialized versions from parent skills. New skill directory, coexists with parents.
- ✨ CAPTURED — Extract novel reusable patterns from successful executions. Brand new skill, no parent.
**Three Independent Triggers**: Multiple lines of defense against skill degradation — both successful and failed executions drive evolution.
- **📈 Post-Execution Analysis** — Runs after every task. Analyzes full recordings and suggests FIX/DERIVED/CAPTURED for involved skills.
- **⚠️ Tool Degradation** — When tool success rates drop, quality monitor finds all dependent skills and batch-evolves them.
- **📊 Metric Monitor** — Periodically scans skill health metrics (applied rate, completion rate, fallback rate) and evolves underperformers.
#### 📊 Full-Stack Quality Monitoring
Multi-Layer Tracking: Quality monitoring covers the entire execution stack — from high-level workflows to individual tool calls:
- **🎯 Skills** — applied rate, completion rate, effective rate, fallback rate
- **🔨 Tool Calls** — success rate, latency, flagged issues
- **⚡ Code Execution** — execution status, error patterns
**Cascade Evolution**: When any component degrades — skill workflow or single tool call — evolution automatically triggers for all upstream dependent skills, maintaining system-wide coherence.
#### 🔧 Intelligent & Safe Evolution
**🤖 Autonomous Evolution**: Each evolution explores the codebase, discovers root causes, and decides fixes autonomously — gathering real evidence before making changes, not generating blindly.
**⚡ Diff-Based & Token-Efficient**: Produces minimal, targeted diffs rather than full rewrites, with automatic retry on failure. Every version stored in a version DAG with full lineage tracking.
**🛡️ Built-in Safeguards**:
- Confirmation gates reduce false-positive triggers
- Anti-loop guards prevent runaway evolution cycles
- Safety checks flag dangerous patterns (prompt injection, credential exfiltration)
- Evolved skills are validated before replacing predecessors
**🌐 Collaborative Skill Community**
A collaborative registry where agents share evolved skills. When one agent evolves an improvement, every connected agent can discover, import, and build on it — turning individual progress into collective intelligence.
- **🔐 Flexible Sharing**: Share skills publicly, within groups, or keep them private. Smart search finds what you need and auto-imports it. Every evolution is lineage-tracked with full diffs.
- **☁️ Collaborative Platform**: open-space.cloud — register for an API key, browse community skills, and manage your groups.
---
## 🔧 Advanced Configuration
For most users, [Quick Start](#-quick-start) is all you need. For advanced options (persistent `settings.json`, environment variables, execution modes, security policies, etc.), see [`openspace/config/README.md`](openspace/config/README.md).
### Skill Evolution Runtime
OpenSpace enables the evolution engine by default in `autonomous` mode:
```env
OPENSPACE_EVOLUTION_ENGINE_ENABLED=1
OPENSPACE_EVOLUTION_MODE=autonomous
```
Modes:
- `audit_only`: record jobs, packets, decisions, and admissions only; no skill writes.
- `fix_only`: explicit/manual FIX jobs such as MCP `fix_skill` can commit after evidence, admission, staged authoring, validation, and commit. DERIVED and CAPTURED actions are recorded as policy-blocked candidates/proposals instead of being committed or auto-rechecked.
- `autonomous`: default; all admission-approved and validated FIX/DERIVED/CAPTURED actions may commit.
Set `OPENSPACE_EVOLUTION_ENGINE_ENABLED=0` only when you want to pause all evolution job processing.
---
<a id="-code-structure"></a>
<details>
<summary><b>📖 Code Structure</b></summary>
> **Legend**: ⚡ Core modules &nbsp;|&nbsp; 🧬 Skill evolution &nbsp;|&nbsp; 🌐 Cloud &nbsp;|&nbsp; 🔧 Supporting modules
```
OpenSpace/
├── openspace/
│ ├── runtime/ # Runtime-owned services, state, session/workspace orchestration, execution lifecycle
│ ├── application.py # Public OpenSpace/OpenSpaceConfig facade; delegates lifecycle to runtime
│ ├── entrypoints/ # CLI, TUI, MCP, gateway, and dashboard process entrypoints
│ │
│ ├── ⚡ agents/ # Agent System
│ │ ├── base.py # Base agent class
│ │ └── grounding_agent.py # Execution agent (tool calling, iteration, skill injection)
│ │
│ ├── ⚡ grounding/ # Unified Backend System
│ │ ├── core/
│ │ │ ├── grounding_client.py # Unified interface across all backends
│ │ │ ├── search_tools.py # Smart Tool RAG (BM25 + embedding + LLM)
│ │ │ ├── quality/ # Tool quality tracking & self-evolution
│ │ │ ├── security/ # Policies, sandboxing, E2B
│ │ │ ├── meta/ # Meta provider & tools
│ │ │ ├── transport/ # Connectors & task managers
│ │ │ └── tool/ # Tool abstraction (base, local, remote)
│ │ └── backends/
│ │ ├── shell/ # Shell command execution
│ │ ├── gui/ # Anthropic Computer Use
│ │ ├── mcp/ # Model Context Protocol (stdio, HTTP, WebSocket)
│ │ └── web/ # Web search & browsing
│ │
│ ├── 🧬 skill_engine/ # Self-Evolving Skill System
│ │ ├── registry.py # Skill catalog, frontmatter parsing, content loading
│ │ ├── protocol.py # Skill/DiscoverSkills tools and listing attachments
│ │ ├── analyzer.py # Post-execution analysis (agent loop + tool access)
│ │ ├── evolver.py # FIX / DERIVED / CAPTURED evolution (3 triggers)
│ │ ├── patch.py # Multi-file FULL / DIFF / PATCH application
│ │ ├── store.py # SQLite persistence, version DAG, quality metrics
│ │ ├── skill_ranker.py # BM25 + embedding hybrid ranking
│ │ ├── fuzzy_match.py # Fuzzy matching for skill discovery
│ │ ├── conversation_formatter.py # Format execution history for analysis
│ │ ├── skill_utils.py # Shared skill utilities
│ │ └── types.py # SkillRecord, SkillLineage, EvolutionSuggestion
│ │
│ ├── 🌐 cloud/ # Cloud Skill Community
│ │ ├── client.py # HTTP client (upload, download, search)
│ │ ├── account.py # User registration and agent-key lifecycle client
│ │ ├── auth_flow.py # High-level account bootstrap and verification flow
│ │ ├── search.py # Hybrid search engine
│ │ ├── embedding.py # Embedding generation for skill search
│ │ └── cli/ # CLI tools (auth, download_skill, upload_skill)
│ │
│ ├── 💬 communication/ # Multi-channel gateway runtime support
│ │ ├── gateway_runtime.py # Gateway locks and runtime status
│ │ ├── runtime_manager.py # Per-channel OpenSpace runtime lifecycle
│ │ ├── adapters/ # Platform adapters (WhatsApp, Feishu)
│ │ ├── bridges/ # Non-Python runtimes (WhatsApp Baileys bridge)
│ │ ├── config.py # Communication config loader
│ │ ├── session_store.py # Per-channel session persistence
│ │ └── types.py # ChannelMessage, ChannelSource, SendResult
│ │
│ ├── 🚪 entrypoints/ # Public CLI/server entrypoints
│ │ ├── cli/main.py # `openspace`
│ │ ├── dashboard/server.py # `openspace-dashboard`
│ │ ├── gateway/server.py # `openspace-gateway`
│ │ ├── mcp/server.py # `openspace-mcp`
│ │ └── tui/controller.py # TypeScript TUI bridge controller
│ │
│ ├── 🔧 platforms/ # Platform abstraction (system info, screenshots)
│ ├── 🔧 host_detection/ # Auto-detect nanobot / openclaw credentials
│ ├── 🔧 host_skills/ # SKILL.md definitions for agent integration
│ │ ├── delegate-task/SKILL.md # Teaches agent: execute, fix, upload
│ │ └── skill-discovery/SKILL.md # Teaches agent: search & discover skills
│ ├── 🔧 prompts/ # LLM prompt templates (grounding + skill engine)
│ ├── 🔧 llm/ # LiteLLM wrapper with retry & rate limiting
│ ├── 🔧 config/ # Layered configuration system
│ ├── 🔧 local_server/ # GUI backend Flask server; shell backend is local-only
│ ├── 🔧 recording/ # Execution recording, screenshots & video capture
│ ├── 🔧 utils/ # Logging, UI, telemetry
│ └── 📦 skills/ # Built-in skills (lowest priority, user can add here)
├── apps/
│ ├── dashboard/ # Dashboard UI (React + Tailwind)
│ └── tui/ # TypeScript terminal UI
├── benchmarks/
│ └── gdpval/ # GDPVal benchmark experiments & results
├── examples/
│ └── my-daily-monitor/ # Generated example app, skills, and evolution DB
├── tests/
│ ├── architecture/ # Import, entrypoint, packaging, manifest gates
│ ├── contracts/ # Frozen public payload contracts
│ ├── unit/ # Owner-aligned unit tests
│ └── integration/ # CLI/MCP/TUI/session/tool/skill/communication flows
├── .openspace/ # Runtime: embedding cache + skill DB
└── logs/ # Execution logs & recordings
```
</details>
---
## 🧪 Testing Layout
Python tests are grouped as focused top-level regressions under
`tests/test_*.py`, architecture gates under `tests/architecture/`, and
unit-level owner checks under `tests/unit/`. `tests/contracts/` and
`tests/integration/` are reserved for restored suites and should not be treated
as passing gates unless they contain `test_*.py` files.
```bash
python -m unittest discover -s tests -p 'test_*.py' -v
python -m unittest discover -s tests/architecture -p 'test_*.py' -v
python -m unittest discover -s tests/unit/grounding -p 'test_*.py' -v
```
For local refactor gates, prefer the targeted suites that match the touched
runtime surface:
```bash
python -m unittest tests.architecture.test_public_entrypoint_convergence -v
python -m unittest tests.test_direct_tool_pipeline_context tests.test_tool_runtime_permission_hooks -v
python -m unittest tests.test_skill_hook_runtime_e2e tests.test_bash_hook_sandbox -v
```
---
## 🤝 Contribute & Roadmap
We welcome contributions! OpenSpace today evolves *how to do X*. The next frontier: **evolving how agents organize doing X together**.
Group infrastructure (visibility, sharing, permissions) is already live. What comes next:
- [ ] **[Kanban](https://github.com/BloopAI/vibe-kanban)-style orchestration** — Shared task board with skill-aware scheduling; scheduling itself evolves
- [ ] **Collaboration pattern evolution** — Decomposition, handoff, prioritization strategies captured and improved from completed tasks
- [ ] **Role emergence** — Agents develop role profiles through practice, not configuration
- [ ] **Cross-group pattern transfer** — Coordination patterns discovered by one group available to others via cloud registry
---
## 🔗 Related Projects
OpenSpace builds upon the following open-source projects. We sincerely thank their authors and contributors:
- **[AnyTool](https://github.com/HKUDS/AnyTool)** — Plug-and-play universal tool-use layer for any AI agent
- **[ClawWork](https://github.com/HKUDS/ClawWork)** - Transforms AI assistants into true AI coworkers
- **[WorldMonitor](https://github.com/koala73/worldmonitor)** - Real-time global intelligence dashboard
---
<div align="center">
## ⭐ Star History
If you find OpenSpace helpful, please consider giving us a star! ⭐
<div align="center">
<a href="https://star-history.com/#HKUDS/OpenSpace&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=HKUDS/OpenSpace&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=HKUDS/OpenSpace&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=HKUDS/OpenSpace&type=Date" />
</picture>
</a>
</div>
**🧬 Make You Agent Self-Evolve · 🌐 A Community That Grows Together · 💰 Fewer Tokens, Smarter Agents**
</div>
---
<p align="center">
<em> ❤️ Thanks for visiting ✨ OpenSpace!</em><br><br>
<img src="https://visitor-badge.laobi.icu/badge?page_id=HKUDS.OpenSpace&style=for-the-badge&color=00d4ff"
alt="Views">
</p>

654
README_CN.md Normal file
View file

@ -0,0 +1,654 @@
<div align="center">
<picture>
<img src="assets/logo.png" width="320px" style="border: none; box-shadow: none;" alt="OpenSpace Logo">
</picture>
## ✨ OpenSpace让你的 Agent 更聪明、更省钱、自我进化 ✨
| 🔋 **Token 用量减少 46%** | **💰 6 小时赚取 $11K** | 🧬 **Skill 自我进化** | 🌐 **Agent 经验共享** |
[![Agents](https://img.shields.io/badge/Agents-Claude_Code%20%7C%20Codex%20%7C%20OpenClaw%20%7C%20nanobot%20%7C%20...-99C9BF.svg)](https://modelcontextprotocol.io/)
[![Python](https://img.shields.io/badge/Python-3.12+-FCE7D6.svg)](https://www.python.org/)
[![License](https://img.shields.io/badge/License-MIT-C1E5F5.svg)](https://opensource.org/licenses/MIT/)
[![Feishu](https://img.shields.io/badge/Feishu-Group-E9DBFC?style=flat&logo=larksuite&logoColor=white)](./COMMUNICATION.md)
[![WeChat](https://img.shields.io/badge/WeChat-Group-C5EAB4?style=flat&logo=wechat&logoColor=white)](./COMMUNICATION.md)
**一条命令,进化你所有的 AI Agent**OpenClaw、nanobot、Claude Code、Codex、Cursor 等
<img src="assets/cli-typing.gif" width="500px" alt="openspace --query your task">
</div>
---
## 📢 最新动态
- **2026-04-09** 💬 多渠道**通信网关**上线。OpenSpace 现可接收并回复外部平台消息。内置 **WhatsApp**Baileys bridge + 扫码认证)与**飞书**HTTP webhook适配器支持会话管理、附件缓存和白名单访问控制。配置方式见 [`openspace/config/README.md`](openspace/config/README.md)。
- **2026-04-07** 🌐 OpenSpace MCP 新增独立 **SSE****streamable HTTP** 启动方式,便于远端 host 通过 HTTP 接入,绕过基于 stdio 的 MCP server timeout 瓶颈。具体接入方式见 [host integration 文档](openspace/host_skills/README.md)。
- **2026-04-06** 🛠️ 修复多项运行时问题,覆盖 grounding、MCP 服务、skill 进化与持久化链路,长流程执行的稳定性与恢复能力进一步提升。
- **2026-04-05** 🧭 LLM 凭证解析清理完成:统一 `.env` 加载逻辑,改进宿主配置自动识别,并让 provider 原生环境变量处理更一致。
- **2026-04-03** 🚀 发布 **v0.1.0** — Skill 质量监控上线:从优质 Skill 中提取结构模式,每日自动评估所有新提交;云端搜索全面升级,匹配更准、响应更快;社区自发形成生产级垂直 Skill 集群。前端新增中文zh国际化支持。
- **2026-04-02** ⚡ 云端搜索升级,提升匹配质量、降低响应延迟。
- **2026-03-31** 🛡️ 安全加固zip 解压与 `import_skill` 新增路径穿越防护CLI 启动时读取 `OPENSPACE_MODEL``OPENSPACE_LLM_*` 环境变量;修复 MiniMax 兼容性问题与 workflow ID 冲突。
- **2026-03-29** 🔒 锁定 litellm 版本至 <1.82.7规避 PYSEC-2026-2 供应链投毒
- **2026-03-28** 🔧 Skill 注册幂等化——`register_skill_dir` 对已注册目录直接返回已有 `SkillMeta`,不再重复创建。同步更新 OpenClaw 部署文档。
- **2026-03-27** 🪟 修复 Windows 下 stdio 死锁evolver 确认解析改用词干匹配,消除误判。
- **2026-03-26** 🌱 Skill 目录支持每次调用时动态重扫描,本地搜索更轻量,文档同步精简。
- **2026-03-25** 🎉 OpenSpace 正式开源!
---
## 当前 AI Agent 面临的问题
如今的 AI Agent——[OpenClaw](https://github.com/openclaw/openclaw)、[nanobot](https://github.com/HKUDS/nanobot)、[Claude Code](https://docs.anthropic.com/en/docs/claude-code)、[Codex](https://github.com/openai/codex)、[Cursor](https://cursor.com) 等——能力强大,但有一个致命弱点:它们从不从真实世界的经验中**学习**、**适应**和**进化**——更不用说相互之间的**共享**了。
- **❌ 大量 Token 浪费** - 如何复用成功的任务模式,而非每次都从零推理、烧掉大量 Token
- **❌ 重复犯下高代价的错误** - 如何在 Agent 之间共享解决方案,而非反复进行同样昂贵的探索和犯同样的错?
- **❌ Skill 质量差且不可靠** - 当工具和 API 持续演变时,如何保证 Skill 的可靠性——同时确保社区贡献的 Skill 达到严格的质量标准?
## 🎯 什么是 OpenSpace
**🚀 🚀 一个自我进化引擎,让每一次任务都能使每个 Agent 变得更聪明、更高效。**
https://github.com/user-attachments/assets/c50f70ab-f6db-47bf-9498-3210c0f0abae
OpenSpace 以 Skill 的形式接入任意 Agent并赋予其三大超能力
### 🧬 自我进化
Skill 能够自动学习并持续提升
- ✅ **自动修复AUTO-FIX** — Skill 出错时,自行即时修复
- ✅ **自动改进AUTO-IMPROVE** — 成功模式自动升级为更优版本
- ✅ **自动学习AUTO-LEARN** — 从实际使用中捕获高效工作流
- ✅ **质量监控** — 跟踪所有任务中的 Skill 表现、错误率和执行成功率
**Skill 持续进化——将每次失败转化为改进,将每次成功转化为优化。**
### 🌐 Agent 集体智慧
将独立的 Agent 联结为共享大脑
- ✅ **共享进化**:一个 Agent 的改进即成为所有 Agent 的升级
- ✅ **网络效应**:更多 Agent → 更丰富的数据 → 每个 Agent 更快进化
- ✅ **便捷共享** — 一行命令即可上传或下载进化后的 Skill
- ✅ **访问控制** — 每项 Skill 可选择公开、私有或仅团队可见
**一个 Agent 学会,所有 Agent 受益——大规模集体智慧。**
### 💰 Token 效率
更聪明的 Agent显著更低的成本
- ✅ **不再重复劳动** → 复用成功方案,而非每次从零开始
- ✅ **任务越做越便宜** → 随着 Skill 改进,类似工作的成本持续下降
- ✅ **只做小幅更新** → 修复损坏的部分,无需全部重建
- ✅ **实际节省**:在真实任务上实现 4.2 倍性能提升、Token 消耗减少 46%,带来可衡量的经济价值。([GDPVal](#-基准测试gdpval)
事半功倍——Agent 真正帮你省钱。
---
### 核心差异
**❌ 当前的 Agent**
- 随着工具更迭Skill 默默退化
- 失败模式反复重演,缺乏学习机制
- 知识封锁在单个 Agent 内
**✅ OpenSpace 赋能的 Agent**
- 多层监控捕捉问题并自动触发修复
- 成功的工作流转化为可复用、可共享的 Skill
- 一个 Agent 学到有用的东西,所有 Agent 即刻获得
### 📊 OpenSpace让你的 Agent 成为能赚钱的同事
**🎯 真实世界的硬核结果**
在 6 个行业的 50 项专业任务(**📈 [GDPVal 经济基准测试](#-基准测试gdpval)**OpenSpace Agent 使用相同的骨干 LLMQwen 3.5-Plus收入是基线[ClawWork](https://github.com/HKUDS/ClawWork)Agent 的 **4.2 倍**,同时通过 Skill 进化节省了 46% 的 Token 开销。
<div align="center">
<img src="assets/benchmark_kpi.png" width="100%" alt="GDPVal 基准测试 — 核心指标" />
</div>
**💼 这些不是玩具级别的问题**
- 根据复杂的工会合同构建工资计算器
- 从 15 份散落的 PDF 文档中准备纳税申报表
- 起草关于加州隐私法规的法律备忘录
- 创建合规表格和工程技术规格书
**📈 在所有领域全面胜出**
- 合规类工作:收入提升 +18.5%
- 工程类项目:性能提升 +8.7%
- 专业文档类Token 需求减少 56%
- 所有类别均有提升——无一例外
<div align="center">
<img src="assets/benchmark_task_showcase.png" width="100%" alt="GDPVal 基准测试 — 各类别任务展示" />
</div>
**OpenSpace 不仅让 Agent 更聪明** —— 更让它们具备经济可行性。真实工作、真实收入、可衡量的成果。
## OpenSpace 自主系统开发案例
**🖥️ [My Daily Monitor](examples/my-daily-monitor/README.md)** — OpenSpace 赋能你的 Agent 完成大规模系统开发。这个拥有 20 多个实时仪表盘面板的个人行为监控系统位于 [`examples/my-daily-monitor`](examples/my-daily-monitor),完全由 Agent 构建——通过 OpenSpace 从零进化出 60 多项 Skill展示了自主端到端软件开发能力。
<div align="center">
<img src="assets/my_daily_monitor_dark.png" width="100%" alt="My Daily Monitor 深色模式" />
</div>
---
## 📋 目录
- [⚡ 快速开始](#-快速开始)
- [🤖 路径 A为你的 Agent 接入](#-路径-a为你的-agent-接入)
- [👤 路径 B作为你的 AI 协作者](#-路径-b作为你的-ai-协作者)
- [📊 本地仪表盘](#-本地仪表盘)
- [📈 基准测试GDPVal](#-基准测试gdpval)
- [📊 案例展示My Daily Monitor](#-案例展示my-daily-monitor)
- [🏗️ 框架](#-框架)
- [🧬 自我进化引擎](#-自我进化引擎)
- [🌐 云端 Skill 社区](#-云端-skill-社区)
- [🔧 高级配置](#-高级配置)
- [📖 代码结构](#-代码结构)
- [🧪 测试结构](#-测试结构)
- [🔗 相关项目](#-相关项目)
---
## ⚡ 快速开始
🌐 **只想看看?****[open-space.cloud](https://open-space.cloud)** 浏览社区 Skill 和进化谱系——无需安装。
```bash
git clone https://github.com/HKUDS/OpenSpace.git && cd OpenSpace
pip install -e .
openspace-mcp --help # 验证安装
```
> [!TIP]
> **Clone 太慢?** `assets/` 目录包含约 50 MB 的图片文件,导致仓库较大。使用以下轻量方式跳过它:
> ```bash
> git clone --filter=blob:none --sparse https://github.com/HKUDS/OpenSpace.git
> cd OpenSpace
> git sparse-checkout set '/*' '!assets/'
> pip install -e .
> ```
**选择你的路径:**
- **[路径 A](#-路径-a为你的-agent-接入)** — 将 OpenSpace 接入你的 Agent
- **[路径 B](#-路径-b作为你的-ai-协作者)** — 直接使用 OpenSpace 作为你的 AI 协作者
### 🤖 路径 A为你的 Agent 接入
适用于任何可启动 MCP server 并读取 Skill`SKILL.md`的宿主。OpenSpace 内置 OpenClaw 与 nanobot 的宿主辅助能力,也可以手动接入 Claude Code、Codex、Cursor 或其他支持 MCP 的 Agent。
**① 将 OpenSpace 添加到你的 Agent 的 MCP 配置中:**
```json
{
"mcpServers": {
"openspace": {
"command": "openspace-mcp",
"toolTimeout": 600,
"env": {
"OPENSPACE_HOST_SKILL_DIRS": "/path/to/your/agent/skills",
"OPENSPACE_WORKSPACE": "/path/to/OpenSpace",
"OPENSPACE_CLOUD_MODE": "live",
"OPENSPACE_CLOUD_API_KEY": "sk-xxx可选用于云端"
}
}
}
}
```
> [!TIP]
> 凭证API 密钥、模型)会从 nanobot 和 OpenClaw 配置中自动检测。其他宿主请设置 `OPENSPACE_LLM_API_KEY` / `OPENSPACE_MODEL`,或使用 `openspace/.env`
> [!NOTE]
> OpenSpace 支持 3 种启动方式:
> - **stdio**:在宿主配置里保留 `command: "openspace-mcp"`
> - **SSE**:先启动 `openspace-mcp --transport sse --host 127.0.0.1 --port 8080`
> - **streamable HTTP**:先启动 `openspace-mcp --transport streamable-http --host 127.0.0.1 --port 8081`
>
> 通用远端 endpoint
> - SSE: `http://127.0.0.1:8080/sse`
> - streamable HTTP: `http://127.0.0.1:8081/mcp`
>
> `stdio` 最简单。HTTP 模式会把 OpenSpace 作为独立服务常驻,但 **不同宿主的注册写法不同**,而且 **调用方自己的 timeout 仍然生效**
**② 将 Skill 复制**到你的 Agent Skill 目录:
```bash
cp -r OpenSpace/openspace/host_skills/delegate-task/ /path/to/your/agent/skills/
cp -r OpenSpace/openspace/host_skills/skill-discovery/ /path/to/your/agent/skills/
```
完成。这两项 Skill 会教你的 Agent 何时以及如何使用 OpenSpace——无需额外提示。你的 Agent 现在可以自我进化 Skill、执行复杂任务、访问云端 Skill 社区。你也可以添加自定义 Skill——参见 [`openspace/skills/README.md`](openspace/skills/README.md)。
> [!NOTE]
> **云端社区(可选):** 运行 `openspace-cloud-auth bootstrap-agent-key --email you@example.com --agent-name openspace-local-agent` 来创建 owner 作用域的 cloud agent key。命令会在本地保存 `OPENSPACE_CLOUD_MODE=live``OPENSPACE_CLOUD_API_KEY`,不会打印原始 key。即使没有云端 key所有本地功能任务执行、进化、本地 Skill 搜索)也能正常运行。
📖 各 Agent 配置OpenClaw / nanobot、所有环境变量、高级设置[`openspace/host_skills/README.md`](openspace/host_skills/README.md)
### 👤 路径 B作为你的 AI 协作者
直接使用 OpenSpace——编码、搜索、工具调用等——内置自我进化 Skill 和云端社区。
> [!NOTE]
> 创建 `.env` 文件并填入你的 LLM API 密钥;如需访问云端社区,用 `openspace-cloud-auth bootstrap-agent-key` 创建并保存 agent key参考 [`openspace/.env.example`](openspace/.env.example))。
```bash
# 交互模式
openspace
# 执行任务
openspace --model "anthropic/claude-sonnet-4-5" --query "Create a monitoring dashboard for my Docker containers"
```
添加自定义 Skill[`openspace/skills/README.md`](openspace/skills/README.md)。
**Cloud CLI** — 通过命令行管理 Skill
```bash
openspace-download-skill <skill_id> # 从云端下载 Skill
openspace-upload-skill /path/to/skill/dir # 上传 Skill 到云端
```
<details>
<summary><b>Python API</b></summary>
```python
import asyncio
from openspace import OpenSpace
from openspace.runtime import ExecutionRequest
async def main():
async with OpenSpace() as cs:
result = await cs.execute(
ExecutionRequest(
prompt="Analyze GitHub trending repos and create a report",
)
)
print(result.text)
for skill in result.evolved_skills:
print(f" Evolved: {skill['name']} ({skill['origin']})")
asyncio.run(main())
```
</details>
### 📊 本地仪表盘
查看你的 Skill 如何进化——浏览 Skill、追踪谱系、比较差异。
> 需要 **Node.js ≥ 20**
```bash
# 终端 1启动后端 API
openspace-dashboard --port 7788
# 终端 2启动前端开发服务器
cd apps/dashboard
npm install # 仅首次需要
npm run dev
```
📖 **前端设置指南**[`apps/dashboard/README.md`](apps/dashboard/README.md)
<div align="center">
<table>
<tr>
<td width="50%"><img src="assets/frontend_1.gif" width="100%" alt="Skill 类别" /></td>
<td width="50%"><img src="assets/frontend_2.gif" width="100%" alt="云端 Skill 记录" /></td>
</tr>
<tr>
<td align="center"><sub>Skill 类别 — 浏览、搜索与排序</sub></td>
<td align="center"><sub>云端 — 浏览与发现 Skill 记录</sub></td>
</tr>
<tr>
<td width="50%"><img src="assets/frontend_3.gif" width="100%" alt="版本谱系" /></td>
<td width="50%"><img src="assets/frontend_4.gif" width="100%" alt="工作流会话" /></td>
</tr>
<tr>
<td align="center"><sub>版本谱系 — Skill 进化图谱</sub></td>
<td align="center"><sub>工作流会话 — 执行历史与指标</sub></td>
</tr>
</table>
</div>
---
## 📈 基准测试GDPVal
我们在 [GDPVal](https://huggingface.co/datasets/openai/gdpval) 上评估 OpenSpace——该数据集包含 220 项真实世界的专业任务,涵盖 44 个职业——采用 [ClawWork](https://github.com/HKUDS/ClawWork) 评测协议,使用相同的生产力工具和基于 LLM 的评分方式。我们的两阶段设计Cold Start → Warm Rerun展示了积累的 Skill 如何随时间降低 Token 消耗。
公平基准OpenSpace 使用 Qwen 3.5-Plus 作为骨干 LLM——与 ClawWork 基线 Agent 完全相同——确保性能差异纯粹来源于 Skill 进化,而非模型能力差异。
真实经济价值:任务涵盖构建工资计算器、准备纳税申报表、起草法律备忘录等——这些都是产生真实 GDP 的专业工作,同时从质量和成本效率两个维度进行评估。
<div align="center">
<img src="assets/benchmark_income.png" width="100%" alt="GDPVal 基准测试 — 收入对比" />
</div>
- **收入提升 4.2 倍** — 相比使用相同骨干 LLMQwen 3.5-Plus的 ClawWork
- **72.8% 价值捕获率** — 在 $15,764 的任务总价值中赚取 $11,484超越所有 Agent
- **70.8% 平均质量** — 比最佳 ClawWork Agent40.8%)高出 30 个百分点
- **Phase 2 的 Token 用量仅为 Phase 1 的 45.9%** — 更好的结果,显著更低的成本
<div align="center">
<img src="assets/benchmark_quality_tokens.png" width="100%" alt="GDPVal 基准测试 — 质量与 Token 效率" />
</div>
### OpenSpace 能处理哪些真实任务?
50 项 GDPVal 任务涵盖 6 个真实工作类别。
- **Phase 1Cold Start** 按顺序执行全部 50 项任务——每项任务完成后Skill 积累到共享数据库中。
- **Phase 2Warm Rerun** 使用 Phase 1 中完整的进化 Skill 库,重新执行相同的 50 项任务。
收入捕获率 = 实际获得报酬 ÷ 任务最大可能价值
<div align="center">
<img src="assets/benchmark_task_showcase.png" width="100%" alt="GDPVal 基准测试 — 各类别任务展示" />
</div>
## 🎯 进化在何处产生最大影响——以及原因:
| 类别 | 收入变化 | Token 变化 | 原因 |
|---|---|---|---|
| **📝 文档与通信** (7) | 71→74% (+3.3pp) | 56% | 规范的正式输出——加州隐私法备忘录、监控调查报告、子女抚养案例报告。`document-gen-fallback` Skill 族历经 13 个版本进化,使结构化输出和错误恢复接近全自动。 |
| **📋 合规与表单** (11) | 51→70% (+18.5pp) | 51% | 结构化 PDF——从 15 份源文档生成纳税申报表、药房合规检查清单、临床交接模板。PDF Skill 链(检查清单逻辑 → reportlab 排版 → 验证)只需进化一次,所有表单任务即可复用完整流水线。 |
| **🎬 媒体制作** (3) | 53→58% (+5.8pp) | 46% | 通过 Python 和 ffmpeg 处理音视频——根据鼓点参考生成巴萨诺瓦器乐、从 5 轨中编辑低音分轨、从 13 段源视频制作 CGI 集锦。进化的 Skill 编码了可用的 ffmpeg 参数和编解码器回退策略,消除了沙箱中的反复试错。 |
| **🛠️ 工程** (4) | 70→78% (+8.7pp) | 43% | 多交付物技术项目——Web3 全栈Solidity + React + 测试、CNC 工作站安全系统(报告 + 布局图 + 硬件表)、航空航天 CFD 报告。协调类 Skill 在这些多样化任务之间通用迁移。 |
| **📊 电子表格** (15) | 63→70% (+7.3pp) | 37% | 功能性 .xlsx 工具——根据工会合同构建工资计算器、基于历史数据预测销售、含竞品对标的定价模型。电子表格模式(公式、合并单元格、数据验证)在各领域完全通用。 |
| **📈 战略与分析** (10) | 88→89% (+1.0pp) | 32% | 战略建议——供应商谈判策略、非营利项目评估、3 亿美元交易台的能源交易分析。质量已处最高水平88%);节省来自于文档结构和多文件编排的复用。 |
### 进化产出了什么165 项 Skill
在 50 项 Phase 1 任务中OpenSpace 自主进化出 **165 项 Skill**。突破性发现:这些不仅是领域知识——它们是**鲁棒的执行模式**和**质量保障工作流**。Agent 学会了如何在不完美的真实世界环境中可靠地交付成果。
**关键发现**:大多数 Skill 聚焦于工具可靠性和错误恢复,而非特定任务知识。
<div align="center">
<img src="assets/benchmark_skill_taxonomy.png" width="100%" alt="GDPVal 基准测试 — 进化 Skill 分类" />
</div>
| 用途 | 数量 | Skill 教会 Agent 什么 |
|---|---|---|
| **文件格式 I/O** | 44 | PDF 解析回退、DOCX 解析、Excel 合并单元格处理、PPTX 创建。其中 32/44 从真实失败中*捕获*——每一条都是生产环境中解决的 Bug。 |
| **执行恢复** | 29 | 分层回退:沙箱失败 → Shell → 写文件后运行 → heredoc。28/29 从实际崩溃中*捕获*。这是使一切其他 Skill 可靠运行的基础。 |
| **文档生成** | 26 | 端到端文档流水线。`document-gen-fallback` 从 1 项导入 Skill 进化为 **13 个衍生版本**——进化最深入的 Skill 族。 |
| **质量保障** | 23 | 写后验证:检查 Excel 行数、验证 PDF 页数、校验电子表格公式。Phase 2 质量提升的关键——Agent 不仅*生产*,还*验证*。 |
| **任务编排** | 17 | 多文件追踪、ZIP 打包、零迭代失败检测。适用于所有多交付物任务类型的元 Skill。 |
| **领域工作流** | 13 | SOAP 病历记录、音频制作(从 1 个模板衍生 **4 代**)、视频流水线。数量虽少,但在各自领域内进化深度显著。 |
| **网络与研究** | 11 | SSL/代理调试、搜索回退、JS 重页面处理。包含 2 项*修复* Skill——网络访问本质上不稳定。 |
**复现实验、分析工具与结果**[`benchmarks/gdpval/README.md`](benchmarks/gdpval/README.md)
---
## 📊 案例展示My Daily Monitor
> **零行人工编写的代码。** 60 多项 Skill 从零进化,构建出一个完整可用的实时仪表盘。
**My Daily Monitor** 是一个常驻运行的仪表盘,实时展示进程、服务器、新闻、市场、邮件和日程——内置 AI Agent。
<div align="center">
<img src="assets/my_daily_monitor_light.png" width="90%" alt="My Daily Monitor 浅色模式" />
</div>
### OpenSpace 如何从零构建它
| 阶段 | 发生了什么 | Skill |
|-------|------------|-------|
| 🌱 **种子期** | 分析开源项目 [WorldMonitor](https://github.com/koala73/worldmonitor),提取参考模式 | 6 项初始 Skill |
| 🏗️ **脚手架** | 生成项目结构、Vite 配置、TypeScript 设置 | +8 项 Skill |
| 🎨 **构建** | 创建 20 多个面板配合数据服务、API 路由、网格布局 | +25 项 Skill |
| 🔧 **修复** | 自动修复 TypeScript 错误、API 不匹配、CSS 冲突 | +12 项 FIX 进化 |
| 🧬 **进化** | 衍生增强模式,合并互补 Skill | +15 项 DERIVED Skill |
| 📦 **捕获** | 从成功执行中提取可复用模式 | +8 项 CAPTURED Skill |
### 📈 Skill 进化图谱
<div align="center">
<img src="assets/my_daily_monitor_evograph.png" width="90%" alt="Skill 进化图谱" />
</div>
> 每个节点代表 OpenSpace 学习、提取或精炼的一项 Skill。完整的进化历史已在 [`examples/my-daily-monitor/.openspace/openspace.db`](examples/my-daily-monitor/.openspace/openspace.db) 中开源,生成的应用源码位于 [`examples/my-daily-monitor`](examples/my-daily-monitor)——可用任意 SQLite 浏览器加载数据库,探索谱系、差异和质量指标。
**完整详情**[`examples/my-daily-monitor/README.md`](examples/my-daily-monitor/README.md)
---
## 🏗️ OpenSpace 框架
<div align="center">
<img src="assets/framework.png" width="90%" alt="OpenSpace 框架" />
</div>
### 🧬 自我进化引擎
OpenSpace 的核心。Skill 不是静态文件——它们是能够自动选择、应用、监控、分析和进化自身的"活"实体。
#### 🔄 自主与持续进化
- **全生命周期管理**:从发现到应用到进化——全程无需人工干预。无论是否存在匹配的 SkillOpenSpace 都能完成任务。
**三种进化模式**
- 🔧 FIX — 就地修复损坏或过时的指令。同一 Skill新版本。
- 🚀 DERIVED — 从父 Skill 创建增强版或专用版。新 Skill 目录,与父 Skill 共存。
- ✨ CAPTURED — 从成功执行中提取全新的可复用模式。全新 Skill无父级。
**三个独立触发器**:多层防线抵御 Skill 退化——无论执行成功还是失败都驱动进化。
- **📈 执行后分析** — 每次任务完成后运行。分析完整记录,为相关 Skill 建议 FIX/DERIVED/CAPTURED。
- **⚠️ 工具退化检测** — 当工具成功率下降时,质量监控器找到所有依赖的 Skill 并批量进化。
- **📊 指标监控** — 定期扫描 Skill 健康指标(应用率、完成率、回退率),进化表现不佳者。
#### 📊 全栈质量监控
多层追踪:质量监控覆盖整个执行栈——从高层工作流到单个工具调用:
- **🎯 Skill** — 应用率、完成率、有效率、回退率
- **🔨 工具调用** — 成功率、延迟、标记的问题
- **⚡ 代码执行** — 执行状态、错误模式
**级联进化**:当任何组件退化时——无论是 Skill 工作流还是单个工具调用——上游所有依赖的 Skill 自动触发进化,维持系统级一致性。
#### 🔧 智能且安全的进化
**🤖 自主进化**:每次进化都会探索代码库、发现根因、自主决定修复——在做出改变之前收集真实证据,而非盲目生成。
**⚡ 基于 Diff 且节省 Token**:生成最小化的、有针对性的 Diff而非全量重写失败时自动重试。每个版本存储在版本 DAG 中,支持完整的谱系追踪。
**🛡️ 内置安全防护**
- 确认门控减少误触发
- 反循环守卫防止进化失控
- 安全检查标记危险模式Prompt Injection、凭证窃取
- 进化后的 Skill 经验证后才替换前代
**🌐 协作 Skill 社区**
一个协作式注册中心Agent 在此共享进化后的 Skill。当一个 Agent 完成改进,所有连接的 Agent 都可以发现、导入并在此基础上构建——将个体进步转化为集体智慧。
- **🔐 灵活共享**:可选择公开分享、团队内分享或保持私有。智能搜索帮你找到所需并自动导入。每次进化都有完整 Diff 的谱系追踪。
- **☁️ 协作平台**open-space.cloud — 注册获取 API 密钥、浏览社区 Skill、管理你的团队。
---
## 🔧 高级配置
对大多数用户而言,[快速开始](#-快速开始)就是你所需的全部。如需高级选项(持久化 `settings.json`、环境变量、执行模式、安全策略等),请参见 [`openspace/config/README.md`](openspace/config/README.md)。
### Skill Evolution Runtime
OpenSpace 默认以 `autonomous` 模式启用进化引擎:
```env
OPENSPACE_EVOLUTION_ENGINE_ENABLED=1
OPENSPACE_EVOLUTION_MODE=autonomous
```
模式:
- `audit_only`:只记录 job、packet、decision 和 admission不写入 Skill。
- `fix_only`:显式/手动 FIX job例如 MCP `fix_skill`)可在完成 evidence、admission、staged authoring、validation 和 commit 后提交。DERIVED 和 CAPTURED action 会记录为 policy-blocked candidate/proposal不会直接提交也不会自动复审。
- `autonomous`:默认模式;所有通过 admission 且完成验证的 FIX/DERIVED/CAPTURED action 都可以提交。
只有在你想暂停所有进化 job 处理时,才设置 `OPENSPACE_EVOLUTION_ENGINE_ENABLED=0`
---
<a id="-代码结构"></a>
<details>
<summary><b>📖 代码结构</b></summary>
> **图例**:⚡ 核心模块 &nbsp;|&nbsp; 🧬 Skill 进化 &nbsp;|&nbsp; 🌐 云端 &nbsp;|&nbsp; 🔧 支撑模块
```
OpenSpace/
├── openspace/
│ ├── runtime/ # Runtime 拥有 services、状态、session/workspace 编排和执行生命周期
│ ├── application.py # OpenSpace/OpenSpaceConfig 公共 facade生命周期委托给 runtime
│ ├── entrypoints/ # CLI、TUI、MCP、gateway 和 dashboard 进程入口
│ │
│ ├── ⚡ agents/ # Agent 系统
│ │ ├── base.py # 基础 Agent 类
│ │ └── grounding_agent.py # 执行 Agent工具调用、迭代、Skill 注入)
│ │
│ ├── ⚡ grounding/ # 统一后端系统
│ │ ├── core/
│ │ │ ├── grounding_client.py # 跨所有后端的统一接口
│ │ │ ├── search_tools.py # 智能工具 RAGBM25 + embedding + LLM
│ │ │ ├── quality/ # 工具质量追踪与自我进化
│ │ │ ├── security/ # 策略、沙箱、E2B
│ │ │ ├── meta/ # Meta provider 与工具
│ │ │ ├── transport/ # 连接器与任务管理器
│ │ │ └── tool/ # 工具抽象(基础、本地、远程)
│ │ └── backends/
│ │ ├── shell/ # Shell 命令执行
│ │ ├── gui/ # Anthropic Computer Use
│ │ ├── mcp/ # Model Context Protocolstdio、HTTP、WebSocket
│ │ └── web/ # 网络搜索与浏览
│ │
│ ├── 🧬 skill_engine/ # 自我进化 Skill 系统
│ │ ├── registry.py # Skill 目录、frontmatter 解析、内容加载
│ │ ├── protocol.py # Skill/DiscoverSkills 工具与 listing attachment
│ │ ├── analyzer.py # 执行后分析Agent 循环 + 工具访问)
│ │ ├── evolver.py # FIX / DERIVED / CAPTURED 进化3 种触发器)
│ │ ├── patch.py # 多文件 FULL / DIFF / PATCH 应用
│ │ ├── store.py # SQLite 持久化、版本 DAG、质量指标
│ │ ├── skill_ranker.py # BM25 + embedding 混合排序
│ │ ├── fuzzy_match.py # Skill 发现的模糊匹配
│ │ ├── conversation_formatter.py # 格式化执行历史以供分析
│ │ ├── skill_utils.py # 共享 Skill 工具函数
│ │ └── types.py # SkillRecord、SkillLineage、EvolutionSuggestion
│ │
│ ├── 🌐 cloud/ # 云端 Skill 社区
│ │ ├── client.py # HTTP 客户端(上传、下载、搜索)
│ │ ├── account.py # 用户注册与 agent key 生命周期客户端
│ │ ├── auth_flow.py # 账号 bootstrap 与校验流程
│ │ ├── search.py # 混合搜索引擎
│ │ ├── embedding.py # Skill 搜索的向量生成
│ │ └── cli/ # CLI 工具auth、download_skill、upload_skill
│ │
│ ├── 💬 communication/ # 多渠道网关运行时支撑
│ │ ├── gateway_runtime.py # 网关锁与运行状态
│ │ ├── runtime_manager.py # 按频道的 OpenSpace runtime 生命周期
│ │ ├── adapters/ # 平台适配器WhatsApp、飞书
│ │ ├── bridges/ # 非 Python 运行时WhatsApp Baileys bridge
│ │ ├── config.py # 通信配置加载
│ │ ├── session_store.py # 按频道的会话持久化
│ │ └── types.py # ChannelMessage, ChannelSource, SendResult
│ │
│ ├── 🚪 entrypoints/ # 公开 CLI / server 入口
│ │ ├── cli/main.py # `openspace`
│ │ ├── dashboard/server.py # `openspace-dashboard`
│ │ ├── gateway/server.py # `openspace-gateway`
│ │ ├── mcp/server.py # `openspace-mcp`
│ │ └── tui/controller.py # TypeScript TUI bridge controller
│ │
│ ├── 🔧 platforms/ # 平台抽象(系统信息、截图)
│ ├── 🔧 host_detection/ # 自动检测 nanobot / openclaw 凭证
│ ├── 🔧 host_skills/ # 面向 Agent 集成的 SKILL.md 定义
│ │ ├── delegate-task/SKILL.md # 教 Agent执行、修复、上传
│ │ └── skill-discovery/SKILL.md # 教 Agent搜索与发现 Skill
│ ├── 🔧 prompts/ # LLM Prompt 模板grounding + Skill 引擎)
│ ├── 🔧 llm/ # LiteLLM 封装,含重试与限流
│ ├── 🔧 config/ # 分层配置系统
│ ├── 🔧 local_server/ # GUI 后端 Flask 服务器Shell 后端仅支持本地模式
│ ├── 🔧 recording/ # 执行录制、截图与视频捕获
│ ├── 🔧 utils/ # 日志、UI、遥测
│ └── 📦 skills/ # 内置 Skill最低优先级用户可在此添加
├── apps/
│ ├── dashboard/ # 仪表盘 UIReact + Tailwind
│ └── tui/ # TypeScript 终端 UI
├── benchmarks/
│ └── gdpval/ # GDPVal 基准测试实验与结果
├── examples/
│ └── my-daily-monitor/ # 生成的示例应用、Skill 与进化数据库
├── tests/
│ ├── architecture/ # import、入口、packaging、manifest 门禁
│ ├── contracts/ # 冻结的公开 payload contract
│ ├── unit/ # 按架构 owner 分组的单元测试
│ └── integration/ # CLI/MCP/TUI/session/tool/skill/communication 流程
├── .openspace/ # 运行时embedding 缓存 + Skill 数据库
└── logs/ # 执行日志与录制
```
</details>
---
## 🧪 测试结构
Python 测试主要分为顶层 `tests/test_*.py` 重点回归、`tests/architecture/`
架构门禁、以及 `tests/unit/` owner 级单元测试。`tests/contracts/`
`tests/integration/` 是恢复套件的保留目录,目录里没有 `test_*.py` 时不能把
discover 空跑当作通过信号。
```bash
python -m unittest discover -s tests -p 'test_*.py' -v
python -m unittest discover -s tests/architecture -p 'test_*.py' -v
python -m unittest discover -s tests/unit/grounding -p 'test_*.py' -v
```
本地重构验证优先使用和本次改动面匹配的 targeted suite
```bash
python -m unittest tests.architecture.test_public_entrypoint_convergence -v
python -m unittest tests.test_direct_tool_pipeline_context tests.test_tool_runtime_permission_hooks -v
python -m unittest tests.test_skill_hook_runtime_e2e tests.test_bash_hook_sandbox -v
```
---
## 🔗 相关项目
OpenSpace 构建于以下开源项目之上。我们衷心感谢其作者和贡献者:
- **[AnyTool](https://github.com/HKUDS/AnyTool)** — 面向任意 AI Agent 的即插即用通用工具层
- **[ClawWork](https://github.com/HKUDS/ClawWork)** — 将 AI 助手转变为真正的 AI 同事
- **[WorldMonitor](https://github.com/koala73/worldmonitor)** — 实时全球情报仪表盘
---
<div align="center">
## ⭐ Star 历史
如果 OpenSpace 对你有帮助,请给我们一颗 Star
<div align="center">
<a href="https://star-history.com/#HKUDS/OpenSpace&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=HKUDS/OpenSpace&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=HKUDS/OpenSpace&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=HKUDS/OpenSpace&type=Date" />
</picture>
</a>
</div>
**🧬 让你的 Agent 自我进化 · 🌐 一个共同成长的社区 · 💰 更少 Token更聪明的 Agent**
</div>
---
<p align="center">
<em> ❤️ 感谢访问 ✨ OpenSpace</em><br><br>
<img src="https://visitor-badge.laobi.icu/badge?page_id=HKUDS.OpenSpace&style=for-the-badge&color=00d4ff"
alt="Views">
</p>

View file

@ -0,0 +1,4 @@
VITE_HOST=127.0.0.1
VITE_PORT=3888
VITE_API_PROXY_TARGET=http://127.0.0.1:7788
VITE_API_BASE_URL=/api/v1

98
apps/dashboard/README.md Normal file
View file

@ -0,0 +1,98 @@
# OpenSpace Frontend
A dashboard frontend for the OpenSpace, providing skill browsing, lineage visualization, and workflow inspection.
## Prerequisites
- **Node.js ≥ 20**
## First-time Setup
1. **Copy the environment file**
```bash
cd apps/dashboard
cp .env.example .env
```
Edit `.env` if your backend runs on a different host/port:
```dotenv
VITE_HOST=127.0.0.1
VITE_PORT=3888
VITE_API_PROXY_TARGET=http://127.0.0.1:7788
VITE_API_BASE_URL=/api/v1
```
2. **Install dependencies**
```bash
npm install
```
3. **Start the backend** (in a separate terminal)
```bash
# option A CLI entry point
openspace-dashboard --host 127.0.0.1 --port 7788
# option B from the repo root
python -m openspace.entrypoints.dashboard.server --host 127.0.0.1 --port 7788
```
> Requires Python ≥ 3.12 with `flask` installed.
4. **Start the frontend**
```bash
npm run dev
```
The dev server will be available at `http://127.0.0.1:3888` (or whatever `VITE_PORT` you set).
## Subsequent Starts
Once `.env` is configured and dependencies are installed, you only need:
```bash
# terminal 1 backend
openspace-dashboard --host 127.0.0.1 --port 7788
# terminal 2 frontend
cd apps/dashboard
npm run dev
```
## Default URLs
| Service | URL |
| ------------------- | ------------------------ |
| Frontend dev server | `http://127.0.0.1:3888` |
| Dashboard API | `http://127.0.0.1:7788` |
## Advanced Configuration
### Bypass the Vite proxy
If you prefer to call the backend directly (e.g. for debugging), you can set `VITE_API_BASE_URL` to the full backend URL:
```bash
VITE_API_BASE_URL=http://127.0.0.1:7788/api/v1 npm run dev
```
## Production Build
```bash
cd apps/dashboard
npm run build
```
After building, start the backend and it will serve `apps/dashboard/dist` as static files automatically — no need to run the dev server.
## Main Pages
- **Dashboard** overall health, pipeline stages, top skills, recent workflows
- **Skills** searchable skill list and score breakdown
- **Skill Detail** source preview, lineage graph, scoring metrics, recent analyses
- **Workflows** recorded workflow sessions from `logs/recordings` and `logs/trajectories`
- **Workflow Detail** timeline, artifacts, metadata, selected skills, plans, and decisions

16
apps/dashboard/index.html Normal file
View file

@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/webp" href="/openspace_icon.webp" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Neuton:ital,wght@0,400;0,700;1,400&family=Cabin:wght@400;500;600&display=swap" rel="stylesheet" />
<title>OpenSpace Dashboard</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3580
apps/dashboard/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,32 @@
{
"name": "openspace-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"copy:packaged": "node ../../scripts/copy-dist-to-packaged.mjs dist ../../openspace/packaged/dashboard",
"build:packaged": "npm run build && npm run copy:packaged",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.7.9",
"i18next": "^26.0.3",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-force-graph-2d": "^1.25.4",
"react-i18next": "^17.0.2",
"react-router-dom": "^7.1.1"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"typescript": "~5.6.2",
"vite": "^6.0.3"
}
}

View file

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

View file

@ -0,0 +1,28 @@
import { Navigate, RouterProvider, createBrowserRouter } from 'react-router-dom';
import MainLayout from './layouts/MainLayout';
import DashboardPage from './pages/DashboardPage';
import EvolutionPage from './pages/EvolutionPage';
import SkillsPage from './pages/SkillsPage';
import SkillDetailPage from './pages/SkillDetailPage';
import WorkflowsPage from './pages/WorkflowsPage';
import WorkflowDetailPage from './pages/WorkflowDetailPage';
const router = createBrowserRouter([
{
path: '/',
element: <MainLayout />,
children: [
{ index: true, element: <Navigate to="/dashboard" replace /> },
{ path: 'dashboard', element: <DashboardPage /> },
{ path: 'evolution', element: <EvolutionPage /> },
{ path: 'skills', element: <SkillsPage /> },
{ path: 'skills/:skillId', element: <SkillDetailPage /> },
{ path: 'workflows', element: <WorkflowsPage /> },
{ path: 'workflows/:workflowId', element: <WorkflowDetailPage /> },
],
},
]);
export default function App() {
return <RouterProvider router={router} />;
}

View file

@ -0,0 +1,11 @@
import axios from 'axios';
const apiClient = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || '/api/v1',
timeout: 15000,
headers: {
'Content-Type': 'application/json',
},
});
export default apiClient;

View file

@ -0,0 +1,136 @@
import apiClient from './client';
import type {
CandidateRecheckResult,
EvidenceRef,
EvidenceRefPreview,
EvolutionAction,
EvolutionCandidate,
EvolutionJob,
EvolutionReviewItem,
QualitySignalAuditRow,
} from './types';
export const evolutionApi = {
async listJobs(params?: { status?: string; limit?: number }): Promise<EvolutionJob[]> {
const response = await apiClient.get<{ items: EvolutionJob[] }>('/evolution/jobs', {
params: {
status: params?.status ?? '',
limit: params?.limit ?? 100,
},
});
return response.data.items;
},
async getJob(jobId: string): Promise<EvolutionJob> {
const response = await apiClient.get<EvolutionJob>(`/evolution/jobs/${encodeURIComponent(jobId)}`);
return response.data;
},
async getPacket(packetId: string): Promise<Record<string, unknown>> {
const response = await apiClient.get<Record<string, unknown>>(
`/evolution/packets/${encodeURIComponent(packetId)}`,
);
return response.data;
},
async getDecision(decisionId: string): Promise<Record<string, unknown>> {
const response = await apiClient.get<Record<string, unknown>>(
`/evolution/decisions/${encodeURIComponent(decisionId)}`,
);
return response.data;
},
async listCandidates(params?: { status?: string; limit?: number }): Promise<EvolutionCandidate[]> {
const response = await apiClient.get<{ items: EvolutionCandidate[] }>('/evolution/candidates', {
params: {
status: params?.status ?? 'pending',
limit: params?.limit ?? 100,
},
});
return response.data.items;
},
async listReviewItems(params?: { limit?: number }): Promise<EvolutionReviewItem[]> {
const response = await apiClient.get<{ items: EvolutionReviewItem[] }>('/evolution/review-items', {
params: {
limit: params?.limit ?? 100,
},
});
return response.data.items;
},
async listQualitySignals(params?: {
actionability?: string;
subjectType?: string;
subjectId?: string;
notTriggerable?: boolean;
limit?: number;
}): Promise<QualitySignalAuditRow[]> {
const response = await apiClient.get<{ items: QualitySignalAuditRow[] }>('/quality-signals', {
params: {
actionability: params?.actionability ?? '',
subject_type: params?.subjectType ?? '',
subject_id: params?.subjectId ?? '',
not_triggerable: params?.notTriggerable ?? false,
limit: params?.limit ?? 100,
},
});
return response.data.items;
},
async listQualitySignalJobs(params?: { limit?: number }): Promise<QualitySignalAuditRow[]> {
const response = await apiClient.get<{ items: QualitySignalAuditRow[] }>('/quality-signals/jobs', {
params: {
limit: params?.limit ?? 100,
},
});
return response.data.items;
},
async getCandidate(candidateId: string): Promise<EvolutionCandidate> {
const response = await apiClient.get<EvolutionCandidate>(
`/evolution/candidates/${encodeURIComponent(candidateId)}`,
);
return response.data;
},
async rejectCandidate(candidateId: string, reason: string): Promise<EvolutionCandidate> {
const response = await apiClient.post<EvolutionCandidate>(
`/evolution/candidates/${encodeURIComponent(candidateId)}/reject`,
{ reason },
);
return response.data;
},
async requestCandidateRecheck(candidateId: string, runNow = true): Promise<CandidateRecheckResult> {
const response = await apiClient.post<CandidateRecheckResult>(
`/evolution/candidates/${encodeURIComponent(candidateId)}/request-recheck`,
null,
{ params: { run_now: runNow } },
);
return response.data;
},
async getAction(actionId: string): Promise<EvolutionAction> {
const response = await apiClient.get<EvolutionAction>(
`/evolution/actions/${encodeURIComponent(actionId)}`,
);
return response.data;
},
async getEvidenceRef(refId: string, includePreview = true): Promise<EvidenceRef> {
const response = await apiClient.get<EvidenceRef>(
`/evidence/refs/${encodeURIComponent(refId)}`,
{ params: { include_preview: includePreview } },
);
return response.data;
},
async previewEvidenceRef(refId: string, maxChars = 2000): Promise<EvidenceRefPreview> {
const response = await apiClient.get<EvidenceRefPreview>(
`/evidence/refs/${encodeURIComponent(refId)}/preview`,
{ params: { max_chars: maxChars } },
);
return response.data;
},
};

View file

@ -0,0 +1,30 @@
export { default as apiClient } from './client';
export { evolutionApi } from './evolution';
export { overviewApi } from './overview';
export { skillsApi } from './skills';
export { workflowsApi } from './workflows';
export type {
CandidateRecheckResult,
EvidenceRef,
EvidenceRefPreview,
ExecutionAnalysis,
EvolutionAction,
EvolutionCandidate,
EvolutionJob,
EvolutionReviewItem,
OverviewResponse,
PipelineStage,
QualitySignalAuditRow,
Skill,
SkillDetail,
SkillLineage,
SkillLineageEdge,
SkillLineageMeta,
SkillLineageNode,
SkillSource,
SkillStats,
WorkflowArtifact,
WorkflowDetail,
WorkflowSummary,
WorkflowTimelineEvent,
} from './types';

View file

@ -0,0 +1,9 @@
import apiClient from './client';
import type { OverviewResponse } from './types';
export const overviewApi = {
async getOverview(): Promise<OverviewResponse> {
const response = await apiClient.get<OverviewResponse>('/overview');
return response.data;
},
};

View file

@ -0,0 +1,31 @@
import apiClient from './client';
import type { Skill, SkillDetail, SkillLineage, SkillStats } from './types';
export const skillsApi = {
async listSkills(params?: { activeOnly?: boolean; sort?: string; limit?: number; query?: string }): Promise<Skill[]> {
const response = await apiClient.get<{ items: Skill[] }>('/skills', {
params: {
active_only: params?.activeOnly ?? true,
sort: params?.sort ?? 'score',
limit: params?.limit ?? 200,
query: params?.query ?? '',
},
});
return response.data.items;
},
async getSkillStats(): Promise<SkillStats> {
const response = await apiClient.get<SkillStats>('/skills/stats');
return response.data;
},
async getSkill(skillId: string): Promise<SkillDetail> {
const response = await apiClient.get<SkillDetail>(`/skills/${skillId}`);
return response.data;
},
async getLineage(skillId: string): Promise<SkillLineage> {
const response = await apiClient.get<SkillLineage>(`/skills/${skillId}/lineage`);
return response.data;
},
};

View file

@ -0,0 +1,353 @@
export interface OverviewResponse {
health: {
status: string;
db_path: string;
evidence_db_path: string;
workflow_count: number;
frontend_dist_exists: boolean;
};
pipeline: PipelineStage[];
skills: {
summary: SkillStats;
average_score: number;
top: Skill[];
recent: Skill[];
};
workflows: {
total: number;
average_success_rate: number;
recent: WorkflowSummary[];
};
}
export interface PipelineStage {
id: string;
title: string;
description: string;
}
export interface ExecutionAnalysis {
task_id: string;
timestamp: string;
task_completed: boolean;
execution_note: string;
tool_issues: string[];
evolution_suggestions: Array<Record<string, unknown>>;
analyzed_by: string;
analyzed_at: string;
}
export interface SkillLineageMeta {
origin: string;
generation: number;
parent_skill_ids: string[];
change_summary: string;
content_diff?: string;
content_snapshot?: Record<string, string>;
evolution_action_id?: string | null;
provenance_refs?: string[];
source_task_id?: string | null;
created_at: string;
created_by: string;
}
export interface SkillLineageNode {
skill_id: string;
name: string;
description: string;
origin: string;
generation: number;
created_at: string;
visibility: string;
is_active: boolean;
tags: string[];
score: number;
effective_rate: number;
total_selections: number;
}
export interface SkillLineageEdge {
source: string;
target: string;
}
export interface SkillLineage {
skill_id: string;
nodes: SkillLineageNode[];
edges: SkillLineageEdge[];
total_nodes: number;
}
export interface SkillSource {
exists: boolean;
path: string;
content: string | null;
}
export interface Skill {
skill_id: string;
name: string;
description: string;
path: string;
skill_dir: string;
is_active: boolean;
category: string;
tags: string[];
visibility: string;
creator_id: string;
lineage: SkillLineageMeta;
origin: string;
generation: number;
parent_skill_ids: string[];
total_selections: number;
total_applied: number;
total_completions: number;
total_fallbacks: number;
applied_rate: number;
completion_rate: number;
effective_rate: number;
fallback_rate: number;
score: number;
first_seen: string;
last_updated: string;
recent_analyses?: ExecutionAnalysis[];
source?: SkillSource;
critical_tools?: string[];
tool_dependencies?: string[];
latest_evolution_action_id?: string | null;
evolution_provenance_refs?: string[];
}
export interface SkillDetail extends Skill {
recent_analyses: ExecutionAnalysis[];
source: SkillSource;
}
export interface SkillStats {
total_skills: number;
total_skills_all: number;
by_category: Record<string, number>;
by_origin: Record<string, number>;
total_analyses: number;
evolution_candidates: number;
total_selections: number;
total_applied: number;
total_completions: number;
total_fallbacks: number;
average_score: number;
skills_with_activity: number;
skills_with_recent_analysis: number;
top_by_effective_rate: Skill[];
}
export interface EvolutionJob {
job_id: string;
trigger_type: string;
status: string;
reason: string;
reason_tags: string[];
scope: Record<string, unknown>;
idempotency_key: string;
evidence_profile: string;
subprofile: string;
manifest_watermark?: number;
attempts?: number;
locked_at?: string | null;
completed_at?: string | null;
result_ref?: string | null;
error?: string | null;
created_at: string;
updated_at?: string | null;
packet_ids: string[];
decision_ids: string[];
admission_ids: string[];
candidate_ids: string[];
validation_ids: string[];
action_ids: string[];
}
export interface EvolutionCandidate {
candidate_id: string;
proposed_action: string;
status: string;
admission_id: string;
source_task_ids: string[];
target_skill_ids: string[];
decision_id: string;
decision_snapshot: Record<string, unknown>;
evidence_refs: string[];
similar_skill_ids: string[];
recurrence: string;
recurrence_count: number;
merge_key: string;
created_at: string;
updated_at: string;
promoted_action_id?: string | null;
rejection_reason?: string | null;
last_recheck_result?: Record<string, unknown> | null;
blocked_reason?: string | null;
needed_evidence?: string[];
}
export interface EvolutionReviewItem {
item_id: string;
item_type: 'candidate' | 'admission' | 'validation';
status: string;
title: string;
summary: string;
created_at: string;
updated_at: string;
candidate_id?: string;
decision_id?: string;
admission_id?: string;
packet_id?: string;
validation_id?: string;
action_kind: 'request_recheck' | 'inspect';
approval_available: boolean;
blocking_stage?: string;
review_note?: string;
}
export interface QualitySignalAuditRow {
signal_ref: string;
signal_type: string;
subject_type: string;
subject_id: string;
tool_key: string;
skill_id: string;
actionability: string;
evidence_status: string;
merge_key: string;
raw_backref_count: number;
job_id: string;
job_status: string;
admission_status: string;
admission_hard_failures: string[];
admission_warnings: string[];
not_triggerable_reason: string;
}
export interface EvolutionAction {
action_id: string;
decision_id: string;
trigger_job_id: string;
authoring_id: string;
validation_id: string;
action_type: string;
commit_status: string;
skill_id?: string | null;
parent_skill_ids: string[];
changed_files: string[];
evidence_refs: string[];
staging_dir: string;
active_target_dir: string;
backup_dir?: string | null;
failure_reason?: string | null;
created_at: string;
committed_at?: string | null;
validation?: Record<string, unknown> | null;
decision?: Record<string, unknown> | null;
failures?: Array<Record<string, unknown>>;
}
export interface EvidenceRef {
ref_id: string;
ref_type: string;
uri: string;
session_id?: string | null;
task_id?: string | null;
producer: string;
created_at: string;
reliability: string;
role: string;
preview: string;
metadata: Record<string, unknown>;
contains_secret?: boolean;
}
export interface EvidenceRefPreview {
ref_id: string;
content: string;
truncated: boolean;
max_chars: number;
}
export interface CandidateRecheckResult {
operation?: string;
job_id: string;
status: string;
job?: EvolutionJob | null;
run_now: boolean;
engine_available: boolean;
recheck_status?: 'executed_committed' | 'executed_no_commit' | 'queued_recheck' | 'queued_no_engine' | 'needs_recovery';
recovery_required?: boolean;
candidate?: EvolutionCandidate | null;
outcomes: Array<Record<string, unknown>>;
}
export interface WorkflowSummary {
id: string;
path: string;
task_id: string;
task_name: string;
instruction: string;
status: string;
iterations: number;
execution_time: number;
start_time: string | null;
end_time: string | null;
total_steps: number;
success_count: number;
success_rate: number;
backend_counts: Record<string, number>;
tool_counts: Record<string, number>;
agent_action_count: number;
has_video: boolean;
video_url: string | null;
screenshot_count: number;
selected_skills: string[];
}
export interface WorkflowArtifact {
name: string;
path: string;
url: string;
}
export interface WorkflowTimelineEvent {
timestamp: string;
type: 'agent_action' | 'tool_execution';
step?: number;
label: string;
agent_name?: string;
agent_type?: string;
backend?: string;
status?: string;
details: Record<string, unknown>;
}
export interface WorkflowDetail extends WorkflowSummary {
metadata: Record<string, unknown>;
statistics: {
total_steps: number;
success_count: number;
success_rate: number;
backends: Record<string, number>;
tools: Record<string, number>;
};
trajectory: Array<Record<string, unknown>>;
plans: Array<Record<string, unknown>>;
decisions: string[];
agent_actions: Array<Record<string, unknown>>;
agent_statistics: {
total_actions: number;
by_agent: Record<string, number>;
by_type: Record<string, number>;
};
timeline: WorkflowTimelineEvent[];
artifacts: {
init_screenshot_url: string | null;
screenshots: WorkflowArtifact[];
video_url: string | null;
};
}

View file

@ -0,0 +1,14 @@
import apiClient from './client';
import type { WorkflowDetail, WorkflowSummary } from './types';
export const workflowsApi = {
async listWorkflows(): Promise<WorkflowSummary[]> {
const response = await apiClient.get<{ items: WorkflowSummary[] }>('/workflows');
return response.data.items;
},
async getWorkflow(workflowId: string): Promise<WorkflowDetail> {
const response = await apiClient.get<WorkflowDetail>(`/workflows/${workflowId}`);
return response.data;
},
};

View file

@ -0,0 +1,13 @@
interface EmptyStateProps {
title: string;
description: string;
}
export default function EmptyState({ title, description }: EmptyStateProps) {
return (
<div className="panel-surface p-8 text-center space-y-2">
<div className="text-lg font-bold font-serif">{title}</div>
<div className="text-sm text-muted">{description}</div>
</div>
);
}

View file

@ -0,0 +1,65 @@
import { Component, ErrorInfo, ReactNode } from 'react';
import i18n from '../i18n';
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
error?: Error;
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
if (import.meta.env.DEV) {
console.error('Error caught by boundary:', error, errorInfo);
}
}
render() {
if (this.state.hasError) {
const t = i18n.t.bind(i18n);
return (
this.props.fallback || (
<div className="min-h-screen flex items-center justify-center bg-[color:var(--color-bg-page)]">
<div className="text-center">
<h1 className="text-2xl font-bold text-[color:var(--color-danger)] mb-4">
{t('errorBoundary.title')}
</h1>
<p className="text-[color:var(--color-muted)] mb-6">
{t('errorBoundary.message')}
</p>
<button
onClick={() => window.location.href = '/dashboard'}
className="btn-primary"
>
{t('errorBoundary.goToDashboard')}
</button>
{import.meta.env.DEV && this.state.error && (
<details className="mt-4 text-left text-xs text-[color:var(--color-muted)]">
<summary>{t('errorBoundary.details')}</summary>
<pre className="mt-2 p-4 bg-[color:var(--color-surface)] overflow-auto">
{this.state.error.stack}
</pre>
</details>
)}
</div>
</div>
)
);
}
return this.props.children;
}
}

View file

@ -0,0 +1,91 @@
import { useMemo, useRef } from 'react';
import ForceGraph2D from 'react-force-graph-2d';
import type { SkillLineage } from '../api';
interface LineageGraphProps {
lineage: SkillLineage;
onNodeClick?: (skillId: string) => void;
}
type GraphNode = {
id: string;
name: string;
score: number;
generation: number;
origin: string;
is_active: boolean;
x?: number;
y?: number;
};
type GraphLink = {
source: string;
target: string;
};
export default function LineageGraph({ lineage, onNodeClick }: LineageGraphProps) {
const fgRef = useRef<any>(null);
const graphData = useMemo<{ nodes: GraphNode[]; links: GraphLink[] }>(
() => ({
nodes: lineage.nodes.map((node) => ({
id: node.skill_id,
name: node.name,
score: node.score,
generation: node.generation,
origin: node.origin,
is_active: node.is_active,
})),
links: lineage.edges.map((edge) => ({
source: edge.source,
target: edge.target,
})),
}),
[lineage],
);
if (graphData.nodes.length === 0) {
return <div className="text-sm text-muted">No lineage graph data.</div>;
}
return (
<div className="panel-surface overflow-hidden">
<div className="px-4 py-3 border-b border-[color:var(--color-border)] text-sm font-bold">
Skill Lineage
</div>
<div className="h-[420px]">
<ForceGraph2D
ref={fgRef}
graphData={graphData}
cooldownTicks={120}
linkColor={() => 'rgba(20, 20, 19, 0.18)'}
linkWidth={1.5}
nodeLabel={(node) => {
const skillNode = node as GraphNode;
return `${skillNode.name}\nscore: ${skillNode.score.toFixed(1)}\norigin: ${skillNode.origin}`;
}}
onNodeClick={(node) => onNodeClick?.((node as GraphNode).id)}
nodeCanvasObject={(node, ctx, globalScale) => {
const skillNode = node as GraphNode;
const label = skillNode.name;
const size = 8 + Math.max(0, skillNode.score / 20);
const fontSize = 12 / globalScale;
const isActive = skillNode.is_active;
ctx.beginPath();
ctx.arc(skillNode.x ?? 0, skillNode.y ?? 0, size, 0, 2 * Math.PI, false);
ctx.fillStyle = isActive ? '#D97757' : '#B8B4A8';
ctx.fill();
ctx.lineWidth = 2 / globalScale;
ctx.strokeStyle = '#141413';
ctx.stroke();
ctx.font = `${fontSize}px ui-monospace`;
ctx.fillStyle = '#141413';
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
ctx.fillText(label, skillNode.x ?? 0, (skillNode.y ?? 0) + size + 4 / globalScale);
}}
/>
</div>
</div>
);
}

View file

@ -0,0 +1,22 @@
import type { ReactNode } from 'react';
interface MetricCardProps {
label: string;
value: ReactNode;
hint?: string;
}
export default function MetricCard({ label, value, hint }: MetricCardProps) {
return (
<div className="metric-card">
<div className="text-xs uppercase tracking-[0.16em] text-muted">{label}</div>
<div className="text-[2.75rem] font-semibold font-serif leading-none tracking-[-0.04em] mt-3">{value}</div>
{hint ? (
<>
<div className="w-8 h-px bg-[color:var(--color-border-dark)] my-4" />
<div className="text-sm text-muted font-serif">{hint}</div>
</>
) : null}
</div>
);
}

View file

@ -0,0 +1,21 @@
interface ProgressBarProps {
label: string;
value: number;
colorClass?: string;
}
export default function ProgressBar({ label, value, colorClass = 'bg-primary' }: ProgressBarProps) {
const percent = Math.max(0, Math.min(100, Math.round(value * 1000) / 10));
return (
<div className="space-y-1">
<div className="flex items-center justify-between text-xs text-muted">
<span>{label}</span>
<span>{percent}%</span>
</div>
<div className="h-3 rounded-full bg-[color:var(--color-border)] overflow-hidden">
<div className={`h-full ${colorClass}`} style={{ width: `${percent}%` }} />
</div>
</div>
);
}

View file

@ -0,0 +1,250 @@
import { useEffect, useMemo, useState, type KeyboardEvent } from 'react';
import { useTranslation } from 'react-i18next';
import type { DiffFile, DiffLine } from '../../utils/diffParser';
interface DiffViewerProps {
files: DiffFile[];
}
interface SplitDiffRow {
leftType: DiffLine['type'] | null;
leftText: string;
leftLineNumber: number | null;
rightType: DiffLine['type'] | null;
rightText: string;
rightLineNumber: number | null;
}
function lineClassName(type: DiffLine['type'] | null): string {
switch (type) {
case 'add':
return 'bg-[color:var(--color-diff-add)] text-[color:var(--color-ink)]';
case 'del':
return 'bg-[color:var(--color-diff-del)] text-[color:var(--color-ink)]';
case 'ctx':
return 'text-[color:var(--color-ink)]';
default:
return 'bg-[rgba(20,20,19,0.03)] text-[color:var(--color-muted)]';
}
}
function linePrefix(type: DiffLine['type'] | null): string {
switch (type) {
case 'add':
return '+';
case 'del':
return '-';
case 'ctx':
return ' ';
default:
return ' ';
}
}
function parseHunkHeader(header: string): { oldLine: number; newLine: number } {
const match = /^@@\s+-(\d+)(?:,\d+)?\s+\+(\d+)(?:,\d+)?\s+@@/.exec(header);
if (!match) {
return { oldLine: 1, newLine: 1 };
}
return {
oldLine: Number.parseInt(match[1], 10),
newLine: Number.parseInt(match[2], 10),
};
}
function pairChangedLines(
deletions: DiffLine[],
additions: DiffLine[],
state: { oldLine: number; newLine: number },
): SplitDiffRow[] {
const rows: SplitDiffRow[] = [];
const maxLength = Math.max(deletions.length, additions.length);
for (let index = 0; index < maxLength; index += 1) {
const left = deletions[index] ?? null;
const right = additions[index] ?? null;
rows.push({
leftType: left?.type ?? null,
leftText: left?.text ?? '',
leftLineNumber: left ? state.oldLine++ : null,
rightType: right?.type ?? null,
rightText: right?.text ?? '',
rightLineNumber: right ? state.newLine++ : null,
});
}
return rows;
}
function buildSplitRows(lines: DiffLine[], header: string): SplitDiffRow[] {
const state = parseHunkHeader(header);
const rows: SplitDiffRow[] = [];
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index];
if (line.type === 'ctx') {
rows.push({
leftType: 'ctx',
leftText: line.text,
leftLineNumber: state.oldLine++,
rightType: 'ctx',
rightText: line.text,
rightLineNumber: state.newLine++,
});
continue;
}
if (line.type === 'del') {
const deletions: DiffLine[] = [];
while (index < lines.length && lines[index]?.type === 'del') {
deletions.push(lines[index]);
index += 1;
}
const additions: DiffLine[] = [];
while (index < lines.length && lines[index]?.type === 'add') {
additions.push(lines[index]);
index += 1;
}
rows.push(...pairChangedLines(deletions, additions, state));
index -= 1;
continue;
}
if (line.type === 'add') {
const additions: DiffLine[] = [];
while (index < lines.length && lines[index]?.type === 'add') {
additions.push(lines[index]);
index += 1;
}
rows.push(...pairChangedLines([], additions, state));
index -= 1;
}
}
return rows;
}
export default function DiffViewer({ files }: DiffViewerProps) {
const { t } = useTranslation();
const [selectedIndex, setSelectedIndex] = useState(0);
const renderableFiles = useMemo(
() => files.filter((file) => file.hunks.some((hunk) => hunk.lines.length > 0)),
[files],
);
useEffect(() => {
setSelectedIndex(0);
}, [renderableFiles]);
if (renderableFiles.length === 0) {
return <p className="text-[color:var(--color-muted)] text-sm">{t('diffViewer.noFiles')}</p>;
}
const activeIndex = selectedIndex < renderableFiles.length ? selectedIndex : 0;
const activeFile = renderableFiles[activeIndex];
const activeHunks = activeFile.hunks.filter((hunk) => hunk.lines.length > 0);
const activeTabId = `diff-file-tab-${activeIndex}`;
const activePanelId = 'diff-file-panel';
function focusTab(index: number) {
const tab = document.querySelector<HTMLButtonElement>(`button[data-diff-tab-index="${index}"]`);
tab?.focus();
}
function handleTabKeyDown(event: KeyboardEvent<HTMLButtonElement>, index: number) {
if (renderableFiles.length < 2) {
return;
}
let nextIndex: number | null = null;
switch (event.key) {
case 'ArrowDown':
case 'ArrowRight':
nextIndex = (index + 1) % renderableFiles.length;
break;
case 'ArrowUp':
case 'ArrowLeft':
nextIndex = (index - 1 + renderableFiles.length) % renderableFiles.length;
break;
case 'Home':
nextIndex = 0;
break;
case 'End':
nextIndex = renderableFiles.length - 1;
break;
default:
return;
}
event.preventDefault();
setSelectedIndex(nextIndex);
focusTab(nextIndex);
}
return (
<div className="flex overflow-hidden rounded-[16px] border border-[color:var(--color-ink)] bg-[color:var(--color-surface)]" style={{ maxHeight: 460 }}>
<nav className="w-[200px] min-w-[200px] border-r-2 border-[color:var(--color-border-dark)] overflow-y-auto bg-[color:var(--color-surface)] p-3">
<ul className="flex flex-col gap-2 text-xs font-mono" role="tablist" aria-label="Diff files" aria-orientation="vertical">
{renderableFiles.map((file, idx) => (
<li key={file.path}>
<button
type="button"
onClick={() => setSelectedIndex(idx)}
onKeyDown={(event) => handleTabKeyDown(event, idx)}
id={`diff-file-tab-${idx}`}
data-diff-tab-index={idx}
role="tab"
aria-selected={idx === activeIndex}
aria-controls={activePanelId}
tabIndex={idx === activeIndex ? 0 : -1}
className={`w-full truncate rounded-full border px-3 py-2 text-left transition-all duration-200 ${
idx === activeIndex
? 'border-[color:var(--color-border-dark)] bg-[color:var(--color-border-dark)] font-bold text-[color:var(--color-ink)] shadow-[inset_0_0_0_1px_var(--color-ink)]'
: 'border-transparent bg-[color:var(--color-bg-page)] text-[color:var(--color-muted)] hover:border-[color:var(--color-border-dark)] hover:text-[color:var(--color-ink)]'
}`}
title={file.path}
>
{file.path}
</button>
</li>
))}
</ul>
</nav>
<div className="flex-1 overflow-auto bg-[color:var(--color-bg-page)]" id={activePanelId} role="tabpanel" aria-labelledby={activeTabId}>
<div className="text-xs font-mono leading-5 min-w-[720px]">
{activeHunks.map((hunk, hunkIdx) => {
const rows = buildSplitRows(hunk.lines, hunk.header);
return (
<div key={`${activeFile.path}-hunk-${hunkIdx}`}>
<div className="sticky top-0 z-10 grid grid-cols-[1fr_1fr] border-y border-[color:var(--color-ink)] bg-[#CBCADB] px-3 py-1.5 text-[color:var(--color-muted)] select-none">
<div>{t('diffViewer.old')}</div>
<div>{t('diffViewer.new')}</div>
</div>
<div className="sticky top-[29px] z-10 border-b border-[color:var(--color-border-dark)] bg-[color:var(--color-surface)] px-3 py-1 text-[color:var(--color-muted)] select-none">
{hunk.header}
</div>
<div className="grid grid-cols-[1fr_1fr]">
{rows.map((row, rowIdx) => (
<div key={`${activeFile.path}-h${hunkIdx}-r${rowIdx}`} className="contents">
<div className={`grid grid-cols-[3rem_1.5rem_minmax(0,1fr)] border-r border-b border-[color:var(--color-border)] px-2 ${lineClassName(row.leftType)}`}>
<span className="select-none opacity-60 text-right pr-2">{row.leftLineNumber ?? ''}</span>
<span className="select-none opacity-60">{linePrefix(row.leftType)}</span>
<span className="whitespace-pre-wrap break-all py-0.5">{row.leftText || ' '}</span>
</div>
<div className={`grid grid-cols-[3rem_1.5rem_minmax(0,1fr)] border-b border-[color:var(--color-border)] px-2 ${lineClassName(row.rightType)}`}>
<span className="select-none opacity-60 text-right pr-2">{row.rightLineNumber ?? ''}</span>
<span className="select-none opacity-60">{linePrefix(row.rightType)}</span>
<span className="whitespace-pre-wrap break-all py-0.5">{row.rightText || ' '}</span>
</div>
</div>
))}
</div>
</div>
);
})}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,229 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import ForceGraph2D from 'react-force-graph-2d';
import type { SkillGraphNode } from '../../hooks/useSkillEvolutionGraphData';
interface SkillGraphLink {
source: string;
target: string;
}
interface SkillEvolutionGraphProps {
graphData: {
nodes: SkillGraphNode[];
links: SkillGraphLink[];
};
selectedNodeId?: string | null;
onNodeClick: (node: SkillGraphNode) => void;
onBackgroundClick?: () => void;
}
const GRAPH_BG = '#FAF9F5';
export default function SkillEvolutionGraph({
graphData,
selectedNodeId,
onNodeClick,
onBackgroundClick,
}: SkillEvolutionGraphProps) {
const { t } = useTranslation();
const graphContainerRef = useRef<HTMLDivElement>(null);
const fgRef = useRef<any>(null);
const [graphDim, setGraphDim] = useState({ width: 0, height: 0 });
const forceConfigured = useRef(false);
useEffect(() => {
if (!graphContainerRef.current) {
return;
}
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (!entry) {
return;
}
const { width, height } = entry.contentRect;
if (width > 0 && height > 0) {
setGraphDim({ width, height });
}
});
observer.observe(graphContainerRef.current);
return () => observer.disconnect();
}, []);
useEffect(() => {
if (!fgRef.current || forceConfigured.current) {
return;
}
forceConfigured.current = true;
const fg = fgRef.current;
fg.d3Force('charge')?.strength(-100);
fg.d3Force('link')?.distance(40);
fg.d3Force('center')?.strength(0.05);
}, [graphDim]);
const paintNode = useCallback((node: object, ctx: CanvasRenderingContext2D, globalScale: number) => {
const graphNode = node as SkillGraphNode;
const normalizedScore = Math.max(0, Math.min(1, graphNode.score / 100));
const baseRadius = 7 + graphNode.usageRatio * 7;
const fontSize = 12 / globalScale;
const x = graphNode.x ?? 0;
const y = graphNode.y ?? 0;
const isSelected = graphNode.id === selectedNodeId;
const red = 184 + Math.round(71 * normalizedScore);
const green = 92 + Math.round(99 * normalizedScore);
const blue = 80 + Math.round(7 * normalizedScore);
const nodeColor = graphNode.isActive
? `rgba(${red}, ${green}, ${blue}, 0.92)`
: `rgba(184, 180, 168, 0.82)`;
const glowColor = graphNode.isActive
? `rgba(${red}, ${green}, ${blue}, 0.45)`
: 'rgba(184, 180, 168, 0.3)';
ctx.beginPath();
ctx.arc(x, y, baseRadius + 1.5, 0, 2 * Math.PI, false);
ctx.fillStyle = GRAPH_BG;
ctx.fill();
ctx.beginPath();
ctx.arc(x, y, baseRadius, 0, 2 * Math.PI, false);
ctx.fillStyle = nodeColor;
ctx.shadowColor = glowColor;
ctx.shadowBlur = isSelected ? 22 : 14;
ctx.fill();
ctx.shadowBlur = 0;
ctx.beginPath();
ctx.arc(x, y, Math.max(2.6, baseRadius * 0.45), 0, 2 * Math.PI, false);
ctx.fillStyle = graphNode.isActive ? 'rgba(255, 255, 255, 0.96)' : 'rgba(255, 255, 255, 0.72)';
ctx.fill();
if (isSelected) {
ctx.beginPath();
ctx.arc(x, y, baseRadius + 4, 0, 2 * Math.PI, false);
ctx.strokeStyle = '#141413';
ctx.lineWidth = 2 / globalScale;
ctx.stroke();
}
ctx.font = `${fontSize}px ui-monospace`;
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
ctx.fillStyle = '#4A3B2A';
ctx.fillText(graphNode.name, x, y + baseRadius + 5 / globalScale);
}, [selectedNodeId]);
const paintLink = useCallback((link: object, ctx: CanvasRenderingContext2D) => {
const source = (link as any).source as SkillGraphNode | undefined;
const target = (link as any).target as SkillGraphNode | undefined;
if (!source || !target) {
return;
}
const sx = source.x;
const sy = source.y;
const tx = target.x;
const ty = target.y;
if (sx === undefined || sy === undefined || tx === undefined || ty === undefined) {
return;
}
const dx = tx - sx;
const dy = ty - sy;
const dist = Math.hypot(dx, dy) || 1;
const nx = -dy / dist;
const ny = dx / dist;
const curveKey = `${source.id}->${target.id}`;
let hash = 0;
for (let i = 0; i < curveKey.length; i += 1) {
hash = ((hash << 5) - hash + curveKey.charCodeAt(i)) | 0;
}
const curveSign = hash % 2 === 0 ? 1 : -1;
const baseOffset = Math.min(14, dist * 0.08) * curveSign;
const c1x = sx + dx * 0.33 + nx * baseOffset;
const c1y = sy + dy * 0.33 + ny * baseOffset;
const c2x = sx + dx * 0.67 + nx * baseOffset;
const c2y = sy + dy * 0.67 + ny * baseOffset;
ctx.save();
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(sx, sy);
ctx.bezierCurveTo(c1x, c1y, c2x, c2y, tx, ty);
ctx.strokeStyle = 'rgba(138, 90, 68, 0.07)';
ctx.lineWidth = 5;
ctx.shadowColor = 'rgba(202, 103, 2, 0.15)';
ctx.shadowBlur = 3;
ctx.shadowOffsetX = 1;
ctx.shadowOffsetY = 1;
ctx.stroke();
const gradient = ctx.createLinearGradient(sx, sy, tx, ty);
gradient.addColorStop(0, 'rgba(189, 140, 99, 0.55)');
gradient.addColorStop(1, 'rgba(148, 104, 70, 0.4)');
ctx.beginPath();
ctx.moveTo(sx, sy);
ctx.bezierCurveTo(c1x, c1y, c2x, c2y, tx, ty);
ctx.strokeStyle = gradient;
ctx.lineWidth = 2.4;
ctx.shadowBlur = 0;
ctx.shadowOffsetX = 0;
ctx.shadowOffsetY = 0;
ctx.stroke();
ctx.restore();
}, []);
if (graphData.nodes.length === 0) {
return <div className="text-sm text-muted p-4">{t('graph.noGraphData')}</div>;
}
return (
<div className="h-[540px] bg-bg-page" ref={graphContainerRef}>
{graphDim.width > 0 && graphDim.height > 0 ? (
<ForceGraph2D
ref={fgRef}
width={graphDim.width}
height={graphDim.height}
graphData={graphData}
cooldownTicks={120}
nodeRelSize={6}
nodeLabel={(node) => {
const graphNode = node as SkillGraphNode;
return [
graphNode.name,
t('graph.tooltipScore', { value: graphNode.score.toFixed(1) }),
t('graph.tooltipGeneration', { value: graphNode.generation }),
t('graph.tooltipOrigin', { value: graphNode.origin }),
].join('\n');
}}
onNodeClick={(node) => {
const graphNode = node as SkillGraphNode;
if (fgRef.current && typeof graphNode.x === 'number' && typeof graphNode.y === 'number') {
fgRef.current.centerAt(graphNode.x, graphNode.y, 600);
fgRef.current.zoom(2.3, 600);
}
onNodeClick(graphNode);
}}
onBackgroundClick={() => onBackgroundClick?.()}
onNodeHover={(node) => {
document.body.style.cursor = node ? 'pointer' : 'default';
}}
nodeCanvasObject={paintNode}
linkCanvasObject={paintLink}
linkCanvasObjectMode={() => 'replace'}
d3AlphaDecay={0.02}
d3VelocityDecay={0.3}
warmupTicks={80}
/>
) : null}
</div>
);
}

View file

@ -0,0 +1,285 @@
import { useLayoutEffect, useMemo, useRef } from 'react';
import { createPortal } from 'react-dom';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import type { SkillDetail } from '../../api';
import { parseDiff } from '../../utils/diffParser';
import EmptyState from '../EmptyState';
import ProgressBar from '../ProgressBar';
import DiffViewer from './DiffViewer';
import { formatDate, formatPercent, truncate } from '../../utils/format';
interface SkillVersionDrawerProps {
skill: SkillDetail | null;
isOpen: boolean;
onClose: () => void;
}
const DRAWER_ANIMATION_DURATION_MS = 300;
const MAX_RENDERABLE_DIFF_LENGTH = 250_000;
const APP_ROOT_SELECTOR = '#root';
const SKILL_MD_FILENAME = 'SKILL.md';
function resolveSourcePreview(skill: SkillDetail) {
const snapshot = skill.lineage.content_snapshot;
if (snapshot && Object.prototype.hasOwnProperty.call(snapshot, SKILL_MD_FILENAME)) {
return {
path: `Version snapshot - ${SKILL_MD_FILENAME}`,
content: snapshot[SKILL_MD_FILENAME] ?? '',
};
}
if (skill.source?.exists && skill.source.content !== null) {
return {
path: skill.source.path || skill.path || SKILL_MD_FILENAME,
content: skill.source.content,
};
}
return null;
}
function lockScroll() {
const html = document.documentElement;
const body = document.body;
const appRoot = document.querySelector<HTMLElement>(APP_ROOT_SELECTOR);
const previouslyFocused = document.activeElement instanceof HTMLElement ? document.activeElement : null;
const supportsStableScrollbarGutter = typeof CSS !== 'undefined' && CSS.supports?.('scrollbar-gutter: stable');
const bodyScrollbarWidth = supportsStableScrollbarGutter ? 0 : Math.max(0, window.innerWidth - html.clientWidth);
html.classList.add('drawer-open');
body.classList.add('drawer-open');
if (appRoot) {
appRoot.inert = true;
appRoot.setAttribute('aria-hidden', 'true');
}
if (bodyScrollbarWidth > 0) {
body.style.paddingRight = `${bodyScrollbarWidth}px`;
}
return () => {
html.classList.remove('drawer-open');
body.classList.remove('drawer-open');
body.style.removeProperty('padding-right');
if (appRoot) {
appRoot.inert = false;
appRoot.removeAttribute('aria-hidden');
}
if (previouslyFocused && previouslyFocused !== body && previouslyFocused.isConnected) {
previouslyFocused.focus({ preventScroll: true });
}
};
}
export default function SkillVersionDrawer({ skill, isOpen, onClose }: SkillVersionDrawerProps) {
const { t } = useTranslation();
const closeButtonRef = useRef<HTMLButtonElement>(null);
const rawDiff = skill?.lineage.content_diff ?? '';
const isOversizedDiff = rawDiff.length > MAX_RENDERABLE_DIFF_LENGTH;
const diffFiles = useMemo(() => (isOversizedDiff ? [] : parseDiff(rawDiff)), [isOversizedDiff, rawDiff]);
const canShowDiff = rawDiff.trim().length > 0;
useLayoutEffect(() => {
if (!skill) {
return;
}
return lockScroll();
}, [skill]);
useLayoutEffect(() => {
if (!isOpen) {
return;
}
closeButtonRef.current?.focus();
}, [isOpen]);
useLayoutEffect(() => {
if (!skill) {
return;
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [onClose, skill]);
if (!skill) {
return null;
}
const sourcePreview = resolveSourcePreview(skill);
const drawerContent = (
<>
<button
type="button"
aria-label="Close version detail"
className={`fixed inset-0 z-30 bg-[rgba(20,20,19,0.22)] transition-opacity duration-300 ${isOpen ? 'pointer-events-auto opacity-100' : 'pointer-events-none opacity-0'}`}
onClick={onClose}
/>
<aside
role="dialog"
aria-modal="true"
aria-labelledby="skill-version-drawer-title"
className={`fixed top-0 right-0 z-40 flex h-full min-w-[28rem] max-w-[65vw] border-l-2 border-[color:var(--color-ink)] bg-[color:var(--color-surface)] shadow-lg will-change-transform ${isOpen ? 'pointer-events-auto' : 'pointer-events-none'}`}
style={{
width: '65vw',
animation: `${isOpen ? 'drawer-slide-in' : 'drawer-slide-out'} ${DRAWER_ANIMATION_DURATION_MS}ms ease-in-out forwards`,
}}
>
<div className="drawer-scroll flex h-full w-full flex-col overflow-hidden overscroll-contain">
<header className="p-4 border-b-2 border-[color:var(--color-border)] flex items-start justify-between gap-3 shrink-0">
<div className="min-w-0">
<p className="text-xs uppercase tracking-wide text-muted">{t('drawer.skillVersion')}</p>
<h2 id="skill-version-drawer-title" className="font-bold text-lg truncate">{skill.name}</h2>
<p className="text-xs text-muted font-mono break-all">{skill.skill_id}</p>
</div>
<div className="flex items-center gap-2">
<Link to={`/skills/${encodeURIComponent(skill.skill_id)}`} className="btn-outline-ink text-sm">
{t('drawer.openAsMain')}
</Link>
<button type="button" onClick={onClose} ref={closeButtonRef} className="btn-outline-ink text-sm">
{t('common.close')}
</button>
</div>
</header>
<main className="drawer-scroll drawer-scroll-region flex-1 overflow-y-auto overscroll-contain space-y-4 bg-bg-page p-4">
<section className="rounded-[var(--radius)] border-2 border-[color:var(--color-border-dark)] bg-surface p-4 space-y-3">
<div className="flex items-start justify-between gap-4">
<div className="space-y-2 min-w-0">
<div className="text-xs uppercase tracking-[0.16em] text-muted">{t('drawer.versionSummary')}</div>
<div className="text-sm text-muted">{skill.description || t('drawer.noDescriptionAvailable')}</div>
<div className="flex flex-wrap gap-2 text-xs">
<span className="tag px-2 py-1">{skill.category}</span>
<span className="tag px-2 py-1">{skill.origin}</span>
<span className="tag px-2 py-1">{t('drawer.gen', { generation: skill.generation })}</span>
<span className="tag px-2 py-1">{skill.is_active ? t('common.active') : t('common.inactive')}</span>
{skill.tags.map((tag) => (
<span key={tag} className="tag px-2 py-1">{tag}</span>
))}
</div>
</div>
<div className="text-right shrink-0">
<div className="text-4xl font-bold font-serif leading-none">{skill.score.toFixed(1)}</div>
<div className="text-xs uppercase tracking-[0.16em] text-muted mt-2">{t('drawer.versionScore')}</div>
</div>
</div>
</section>
<section className="rounded-[var(--radius)] border-2 border-[color:var(--color-border-dark)] bg-surface p-4 space-y-4">
<div>
<div className="text-xs uppercase tracking-[0.16em] text-muted">{t('drawer.metrics')}</div>
<h3 className="text-xl font-bold font-serif mt-1">{t('drawer.executionQuality')}</h3>
</div>
<div className="space-y-4">
<ProgressBar label={t('drawer.effectiveRate')} value={skill.effective_rate} colorClass="bg-primary" />
<ProgressBar label={t('drawer.completionRate')} value={skill.completion_rate} colorClass="bg-accent" />
<ProgressBar label={t('drawer.appliedRate')} value={skill.applied_rate} colorClass="bg-teal" />
<ProgressBar label={t('drawer.fallbackRate')} value={skill.fallback_rate} colorClass="bg-danger" />
</div>
<div className="grid grid-cols-2 gap-3 text-sm text-muted">
<div><div className="font-bold text-ink">{t('drawer.selectionsLabel')}</div><div>{skill.total_selections}</div></div>
<div><div className="font-bold text-ink">{t('drawer.appliedLabel')}</div><div>{skill.total_applied}</div></div>
<div><div className="font-bold text-ink">{t('drawer.completionsLabel')}</div><div>{skill.total_completions}</div></div>
<div><div className="font-bold text-ink">{t('drawer.fallbacksLabel')}</div><div>{skill.total_fallbacks}</div></div>
</div>
</section>
<section className="rounded-[var(--radius)] border-2 border-[color:var(--color-border-dark)] bg-surface p-4 text-sm space-y-2">
<h3 className="font-bold">{t('drawer.versionMetadata')}</h3>
<p><strong>{t('drawer.origin')}</strong> {skill.origin}</p>
<p><strong>{t('drawer.generation')}</strong> {skill.generation}</p>
<p><strong>{t('drawer.visibility')}</strong> {skill.visibility}</p>
<p><strong>{t('drawer.created')}</strong> {formatDate(skill.lineage.created_at)}</p>
<p><strong>{t('drawer.firstSeen')}</strong> {formatDate(skill.first_seen)}</p>
<p><strong>{t('drawer.lastUpdated')}</strong> {formatDate(skill.last_updated)}</p>
<p><strong>{t('drawer.skillPath')}</strong> <span className="break-all">{skill.path || t('common.unavailable')}</span></p>
<p><strong>{t('drawer.skillDir')}</strong> <span className="break-all">{skill.skill_dir || t('common.unavailable')}</span></p>
<p><strong>{t('drawer.parentIds')}</strong> {skill.parent_skill_ids.length ? skill.parent_skill_ids.join(', ') : t('common.none')}</p>
<p><strong>{t('drawer.evolutionAction')}</strong> <span className="break-all">{skill.latest_evolution_action_id || t('common.none')}</span></p>
<p><strong>{t('drawer.provenanceRefs')}</strong> <span className="break-all">{skill.evolution_provenance_refs?.length ? skill.evolution_provenance_refs.join(', ') : t('common.none')}</span></p>
<p><strong>{t('drawer.changeSummary')}</strong> {skill.lineage.change_summary || t('common.none')}</p>
<p><strong>{t('drawer.effectiveScore')}</strong> {formatPercent(skill.effective_rate)}</p>
</section>
<section className="rounded-[var(--radius)] border-2 border-[color:var(--color-border-dark)] bg-surface p-4 space-y-4">
<div>
<div className="text-xs uppercase tracking-[0.16em] text-muted">{t('drawer.diff')}</div>
<h3 className="text-xl font-bold font-serif mt-1">{t('drawer.contentDiff')}</h3>
</div>
{isOversizedDiff ? (
<EmptyState title={t('drawer.diffTooLarge')} description={t('drawer.diffTooLargeDesc')} />
) : canShowDiff ? (
diffFiles.length > 0 ? (
<DiffViewer files={diffFiles} />
) : (
<EmptyState title={t('drawer.diffUnavailable')} description={t('drawer.diffUnavailableDesc')} />
)
) : (
<EmptyState title={t('drawer.noContentDiff')} description={t('drawer.noContentDiffDesc')} />
)}
</section>
<section className="rounded-[var(--radius)] border-2 border-[color:var(--color-border-dark)] bg-surface p-4 space-y-4">
<div>
<div className="text-xs uppercase tracking-[0.16em] text-muted">{t('drawer.source')}</div>
<h3 className="text-xl font-bold font-serif mt-1">{t('drawer.skillMdPreview')}</h3>
</div>
{sourcePreview ? (
<div className="space-y-3">
<div className="text-xs text-muted break-all">{sourcePreview.path}</div>
<pre className="field-surface p-4 text-xs overflow-auto max-h-[320px] whitespace-pre-wrap">{sourcePreview.content}</pre>
</div>
) : (
<EmptyState title={t('drawer.sourceUnavailable')} description={t('drawer.sourceUnavailableDesc')} />
)}
</section>
<section className="rounded-[var(--radius)] border-2 border-[color:var(--color-border-dark)] bg-surface p-4 space-y-4">
<div>
<div className="text-xs uppercase tracking-[0.16em] text-muted">{t('drawer.analyses')}</div>
<h3 className="text-xl font-bold font-serif mt-1">{t('drawer.recentAnalyses')}</h3>
</div>
{skill.recent_analyses.length > 0 ? (
<div className="space-y-3">
{skill.recent_analyses.map((analysis) => (
<div key={`${analysis.task_id}-${analysis.timestamp}`} className="panel-subtle p-4 bg-surface space-y-2">
<div className="flex items-center justify-between gap-3">
<div className="font-bold truncate">{analysis.task_id}</div>
<div className="text-xs text-muted">{formatDate(analysis.timestamp)}</div>
</div>
<div className="text-sm text-muted">{truncate(analysis.execution_note || t('drawer.noExecutionNote'), 220)}</div>
<div className="text-xs text-muted">
{t('drawer.analysisCompleted', {
value: analysis.task_completed ? t('common.yes') : t('common.no'),
toolIssues: analysis.tool_issues.length,
suggestions: analysis.evolution_suggestions.length,
})}
</div>
</div>
))}
</div>
) : (
<EmptyState title={t('drawer.noAnalysesYet')} description={t('drawer.noAnalysesDesc')} />
)}
</section>
</main>
</div>
</aside>
</>
);
if (typeof document === 'undefined') {
return drawerContent;
}
return createPortal(drawerContent, document.body);
}

View file

@ -0,0 +1,53 @@
import { useTranslation } from 'react-i18next';
interface SkillVersionFilterBarProps {
originFilter: string;
onOriginFilterChange: (value: string) => void;
tagFilter: string;
onTagFilterChange: (value: string) => void;
allOrigins: string[];
allTags: string[];
}
export default function SkillVersionFilterBar({
originFilter,
onOriginFilterChange,
tagFilter,
onTagFilterChange,
allOrigins,
allTags,
}: SkillVersionFilterBarProps) {
const { t } = useTranslation();
return (
<div className="flex items-center gap-4 flex-wrap">
<div className="flex items-center gap-2">
<label className="text-sm font-medium">{t('filter.origin')}</label>
<select
value={originFilter}
onChange={(event) => onOriginFilterChange(event.target.value)}
className="border border-[color:var(--color-ink)] bg-transparent px-2 py-1 text-sm"
>
<option value="all">{t('filter.allOrigins')}</option>
{allOrigins.map((origin) => (
<option key={origin} value={origin}>{origin}</option>
))}
</select>
</div>
<div className="flex items-center gap-2">
<label className="text-sm font-medium">{t('filter.tags')}</label>
<select
value={tagFilter}
onChange={(event) => onTagFilterChange(event.target.value)}
className="border border-[color:var(--color-ink)] bg-transparent px-2 py-1 text-sm"
>
<option value="all">{t('filter.allTags')}</option>
{allTags.map((tag) => (
<option key={tag} value={tag}>{tag}</option>
))}
</select>
</div>
</div>
);
}

View file

@ -0,0 +1,197 @@
import { useEffect, useMemo, useRef } from 'react';
import type { SkillLineage, SkillLineageNode } from '../api';
export interface SkillGraphNode {
id: string;
name: string;
score: number;
origin: string;
generation: number;
created_at: string;
visibility: string;
totalSelections: number;
effectiveRate: number;
isActive: boolean;
tags: string[];
usageRatio: number;
x?: number;
y?: number;
vx?: number;
vy?: number;
fx?: number;
fy?: number;
}
interface SkillGraphLink {
source: string;
target: string;
}
interface UseSkillEvolutionGraphDataResult {
allOrigins: string[];
allTags: string[];
graphData: {
nodes: SkillGraphNode[];
links: SkillGraphLink[];
};
}
function createGraphNode(node: SkillLineageNode, maxSelections: number): SkillGraphNode {
const usageRatio = maxSelections > 0 ? 0.25 + 0.75 * (node.total_selections / maxSelections) : 0.35;
return {
id: node.skill_id,
name: node.name || node.skill_id.slice(0, 8),
score: node.score,
origin: node.origin,
generation: node.generation,
created_at: node.created_at,
visibility: node.visibility,
totalSelections: node.total_selections,
effectiveRate: node.effective_rate,
isActive: node.is_active,
tags: node.tags,
usageRatio,
};
}
export function useSkillEvolutionGraphData(
lineage: SkillLineage | null,
originFilter: string,
tagFilter: string,
alwaysVisibleSkillIds: string[] = [],
): UseSkillEvolutionGraphDataResult {
const cachedNodesByLineageRef = useRef(new Map<string, Map<string, SkillGraphNode>>());
const allOrigins = useMemo(() => {
if (!lineage) {
return [];
}
return Array.from(new Set(lineage.nodes.map((node) => node.origin))).sort();
}, [lineage]);
const allTags = useMemo(() => {
if (!lineage) {
return [];
}
const tags = new Set<string>();
lineage.nodes.forEach((node) => {
node.tags.forEach((tag) => tags.add(tag));
});
return Array.from(tags).sort();
}, [lineage]);
const filteredLineage = useMemo(() => {
if (!lineage) {
return null;
}
const pinnedSkillIds = new Set(alwaysVisibleSkillIds.filter(Boolean));
const visibleNodeIds = new Set<string>();
const filteredNodes = lineage.nodes.filter((node) => {
if (pinnedSkillIds.has(node.skill_id)) {
return true;
}
if (originFilter !== 'all' && node.origin !== originFilter) {
return false;
}
if (tagFilter !== 'all' && !node.tags.includes(tagFilter)) {
return false;
}
return true;
});
filteredNodes.forEach((node) => visibleNodeIds.add(node.skill_id));
const filteredEdges = lineage.edges.filter(
(edge) => visibleNodeIds.has(edge.source) && visibleNodeIds.has(edge.target),
);
return {
skill_id: lineage.skill_id,
nodes: filteredNodes,
edges: filteredEdges,
total_nodes: filteredNodes.length,
};
}, [alwaysVisibleSkillIds, lineage, originFilter, tagFilter]);
const lineageNodeIds = useMemo(
() => new Set((lineage?.nodes ?? []).map((node) => node.skill_id)),
[lineage],
);
const currentLineageId = filteredLineage?.skill_id ?? null;
const { graphData, visibleNodes } = useMemo(() => {
if (!filteredLineage) {
return {
graphData: { nodes: [], links: [] },
visibleNodes: new Map<string, SkillGraphNode>(),
};
}
const activeCache = currentLineageId
? cachedNodesByLineageRef.current.get(currentLineageId) ?? new Map<string, SkillGraphNode>()
: new Map<string, SkillGraphNode>();
const maxSelections = Math.max(...filteredLineage.nodes.map((node) => node.total_selections), 0);
const nextVisibleNodes = new Map<string, SkillGraphNode>();
const nodes = filteredLineage.nodes.map((node) => {
const existingNode = activeCache.get(node.skill_id);
const graphNode = existingNode ?? createGraphNode(node, maxSelections);
const freshNode = createGraphNode(node, maxSelections);
graphNode.id = freshNode.id;
graphNode.name = freshNode.name;
graphNode.score = freshNode.score;
graphNode.origin = freshNode.origin;
graphNode.generation = freshNode.generation;
graphNode.created_at = freshNode.created_at;
graphNode.visibility = freshNode.visibility;
graphNode.totalSelections = freshNode.totalSelections;
graphNode.effectiveRate = freshNode.effectiveRate;
graphNode.isActive = freshNode.isActive;
graphNode.tags = freshNode.tags;
graphNode.usageRatio = freshNode.usageRatio;
nextVisibleNodes.set(node.skill_id, graphNode);
return graphNode;
});
return {
graphData: {
nodes,
links: filteredLineage.edges.map((edge) => ({
source: edge.source,
target: edge.target,
})),
},
visibleNodes: nextVisibleNodes,
};
}, [currentLineageId, filteredLineage]);
useEffect(() => {
if (!currentLineageId) {
return;
}
const nextLineageCache = new Map(
cachedNodesByLineageRef.current.get(currentLineageId) ?? new Map<string, SkillGraphNode>(),
);
visibleNodes.forEach((node, nodeId) => {
nextLineageCache.set(nodeId, node);
});
for (const nodeId of nextLineageCache.keys()) {
if (!lineageNodeIds.has(nodeId)) {
nextLineageCache.delete(nodeId);
}
}
const nextCaches = new Map(cachedNodesByLineageRef.current);
nextCaches.set(currentLineageId, nextLineageCache);
cachedNodesByLineageRef.current = nextCaches;
}, [currentLineageId, lineageNodeIds, visibleNodes]);
return { allOrigins, allTags, graphData };
}

View file

@ -0,0 +1,357 @@
{
"nav": {
"dashboard": "Dashboard",
"evolution": "Evolution",
"skills": "Skills",
"workflows": "Workflows"
},
"common": {
"loading": "Loading…",
"yes": "yes",
"no": "no",
"none": "None",
"unavailable": "Unavailable",
"unknown": "Unknown",
"close": "Close",
"score": "score",
"success": "success",
"active": "active",
"inactive": "inactive",
"noDescription": "No description",
"steps_one": "{{count}} step",
"steps_other": "{{count}} steps",
"agentActions_one": "{{count}} agent action",
"agentActions_other": "{{count}} agent actions",
"tags": "+{{count}} tags"
},
"langSwitch": {
"label": "Language"
},
"errorBoundary": {
"title": "Something went wrong",
"message": "An unexpected error occurred",
"goToDashboard": "Go to Dashboard",
"details": "Error details"
},
"dashboard": {
"title": "Dashboard",
"loadingDashboard": "Loading dashboard…",
"failedToLoad": "Failed to load overview",
"dashboardUnavailable": "Dashboard unavailable",
"totalSkills": "Total Skills",
"activeHint": "Active: {{count}}",
"avgSkillScore": "Average Skill Score",
"avgScoreHint": "Primary metric = effective rate × 100",
"workflowSessions": "Workflow Sessions",
"localRepo": "local repo",
"workspace": "workspace",
"recordedUnder": "Recorded under {{location}}",
"workflowSuccess": "Workflow Success",
"avgSuccessHint": "Average session success rate",
"health": "Health",
"runtimeSnapshot": "Runtime snapshot",
"status": "Status",
"dbPath": "DB Path",
"evidenceDbPath": "Evidence DB Path",
"workflowCount": "Workflow Count",
"builtFrontend": "Built Frontend",
"skillsSection": "Skills",
"topScoredSkills": "Top scored skills",
"noSkillsYet": "No skills yet",
"noSkillsDesc": "Run OpenSpace tasks or sync skills into the local registry first.",
"effective": "effective {{value}}",
"applied": "applied {{value}}",
"selections": "selections {{count}}",
"workflowsSection": "Workflows",
"recentSessions": "Recent sessions",
"noWorkflowSessions": "No workflow sessions",
"noWorkflowDesc": "Recordings will appear after a task is executed with recording enabled."
},
"skills": {
"title": "Skill classes",
"searchPlaceholder": "Search by name, id, description, tag, or origin",
"sortByScore": "Sort by best score",
"sortByUpdated": "Sort by updated time",
"sortByName": "Sort by name",
"skillClasses": "Skill Classes",
"versionsHint": "Versions: {{count}}",
"activeVersions": "Active Versions",
"withActivity": "With activity: {{count}}",
"avgBestScore": "Average Best Score",
"bestNodeScoreHint": "Best node score per class",
"selections": "Selections",
"completionsHint": "Completions: {{count}}",
"loadingSkills": "Loading skills…",
"failedToLoad": "Failed to load skills",
"noSkillsMatch": "No skills match",
"noSkillsMatchDesc": "Try another keyword, or execute tasks so new skill telemetry lands in SQLite.",
"bestScore": "best score",
"noClassDescription": "No class description",
"versions": "{{count}} versions",
"active": "{{count}} active",
"selectionsCount": "{{count}} selections"
},
"skillDetail": {
"loadingDetail": "Loading skill detail…",
"failedToLoad": "Failed to load skill class",
"skillNotFound": "Skill not found",
"backToSkills": "← Back to Skills",
"anchoredOn": "Skill class anchored on {{id}}",
"skillClass": "Skill Class",
"evolutionOverview": "Evolution overview",
"activeTip": "active tip",
"inactiveAnchor": "inactive anchor",
"bestVersionScore": "best version score",
"skillDirectory": "Skill directory",
"latestVersionCreated": "Latest version created",
"representativeVersion": "Representative version",
"representativeUpdate": "Representative update",
"versions": "Versions",
"maxGeneration": "Max generation {{count}}",
"activeVersions": "Active Versions",
"originsCount": "Origins: {{count}}",
"averageScore": "Average Score",
"acrossAllVersions": "Across all versions in this lineage",
"selections": "Selections",
"representativeScore": "Representative score {{score}}",
"evolutionGraph": "Evolution Graph",
"versionLineage": "Version lineage",
"loadingDrawer": "Loading version drawer…",
"noLineageGraph": "No lineage graph",
"noLineageGraphDesc": "This skill does not yet have lineage data to visualize."
},
"workflows": {
"title": "Recorded sessions",
"searchPlaceholder": "Search by task name or instruction",
"workflowSessions": "Workflow Sessions",
"scannedFrom": "Scanned from logs/recordings and logs/trajectories",
"averageSuccess": "Average Success",
"meanSuccessRate": "Mean success rate across sessions",
"loadingWorkflows": "Loading workflows…",
"failedToLoad": "Failed to load workflows",
"noSessions": "No workflow sessions",
"noSessionsDesc": "Run `openspace` with recording enabled, then refresh this page.",
"more": "+{{count}} more"
},
"workflowDetail": {
"loadingDetail": "Loading workflow detail…",
"failedToLoad": "Failed to load workflow",
"workflowNotFound": "Workflow not found",
"backToWorkflows": "← Back to Workflows",
"workflowDetail": "Workflow detail",
"skillsSelected_one": "{{count}} skill selected",
"skillsSelected_other": "{{count}} skills selected",
"mergedEvent_one": "{{count}} merged event",
"mergedEvent_other": "{{count}} merged events",
"runDescription": "{{status}} run with {{iterations}} and {{actions}}.",
"started": "Started",
"duration": "Duration",
"stepsLabel": "Steps",
"latestEvent": "Latest event",
"successRate": "Success rate",
"successRateHint_one": "{{count}} successful iteration out of {{iterations}}",
"successRateHint_other": "{{count}} successful iterations out of {{iterations}}",
"iterations": "Iterations",
"totalRuntime": "{{duration}} total runtime",
"activeBackends": "Active backends",
"mostActive": "Most active {{backend}} · {{count}} events",
"noRecordedActivity": "No recorded tool activity",
"timelineEvents": "Timeline events",
"agentToolHint": "{{agentCount}} agent actions · {{toolCount}} tool events",
"noTimelineData": "No timeline data",
"noTimelineDesc": "This session does not yet contain trajectory or agent action records.",
"rawEventJson": "Raw event JSON",
"selection": "Selection",
"selectedSkills": "Selected skills",
"noSelectedSkills": "No selected skills",
"noSelectedSkillsDesc": "No skills were selected or recorded for this run.",
"session": "Session",
"overview": "Overview",
"taskId": "Task ID",
"runtime": "Runtime",
"window": "Window",
"ended": "Ended {{date}}",
"selectionMethod": "Selection method",
"notRecorded": "Not recorded",
"enabledBackends": "Enabled backends",
"backendActivity": "Backend activity",
"iteration_one": "{{count}} iteration",
"iteration_other": "{{count}} iterations",
"step_one": "{{count}} step",
"step_other": "{{count}} steps",
"agentAction_one": "{{count}} agent action",
"agentAction_other": "{{count}} agent actions",
"successfulIteration_one": "{{count}} successful iteration",
"successfulIteration_other": "{{count}} successful iterations"
},
"drawer": {
"skillVersion": "Skill Version",
"openAsMain": "Open as main",
"versionSummary": "Version Summary",
"noDescriptionAvailable": "No description available for this version.",
"gen": "gen {{generation}}",
"versionScore": "version score",
"metrics": "Metrics",
"executionQuality": "Execution quality",
"effectiveRate": "Effective rate",
"completionRate": "Completion rate",
"appliedRate": "Applied rate",
"fallbackRate": "Fallback rate",
"selectionsLabel": "Selections",
"appliedLabel": "Applied",
"completionsLabel": "Completions",
"fallbacksLabel": "Fallbacks",
"versionMetadata": "Version Metadata",
"origin": "Origin:",
"generation": "Generation:",
"visibility": "Visibility:",
"created": "Created:",
"firstSeen": "First seen:",
"lastUpdated": "Last updated:",
"skillPath": "Skill path:",
"skillDir": "Skill dir:",
"parentIds": "Parent IDs:",
"evolutionAction": "Evolution action:",
"provenanceRefs": "Provenance refs:",
"changeSummary": "Change summary:",
"effectiveScore": "Effective score:",
"diff": "Diff",
"contentDiff": "Content diff",
"diffTooLarge": "Diff too large",
"diffTooLargeDesc": "This version has a very large content diff, so the inline viewer is disabled.",
"diffUnavailable": "Diff unavailable",
"diffUnavailableDesc": "This version has a diff payload, but it could not be parsed as a unified diff.",
"noContentDiff": "No content diff",
"noContentDiffDesc": "This version does not have a stored content diff.",
"source": "Source",
"skillMdPreview": "SKILL.md preview",
"sourceUnavailable": "Source unavailable",
"sourceUnavailableDesc": "This version points to a missing or unreadable SKILL.md path.",
"analyses": "Analyses",
"recentAnalyses": "Recent execution analyses",
"noExecutionNote": "No execution note",
"analysisCompleted": "completed: {{value}} · tool issues: {{toolIssues}} · suggestions: {{suggestions}}",
"noAnalysesYet": "No analyses yet",
"noAnalysesDesc": "Execution analyses will appear after recorded task runs are persisted into SQLite."
},
"evolution": {
"title": "Evolution audit",
"refresh": "Refresh",
"failedToLoad": "Failed to load evolution audit data",
"failedToLoadSignals": "Failed to load quality signal audit data",
"failedToLoadDetails": "Failed to load audit details",
"failedToLoadRef": "Failed to load evidence ref",
"failedToUpdateCandidate": "Failed to update candidate",
"openJobs": "Open Jobs",
"visibleJobs": "{{count}} visible jobs",
"failedJobs": "Failed Jobs",
"jobFilter": "Job filter: {{status}}",
"pendingCandidates": "Pending Candidates",
"candidateFilter": "Candidate filter: {{status}}",
"pendingReviewItems": "Review Items",
"reviewQueueHint": "Actionable candidate approvals and inspect-only blockers",
"linkedActions": "Linked Actions",
"actionsFromJobs": "Action refs from visible jobs",
"qualitySignalAudit": "Quality signal audit",
"qualitySignals": "Quality signals",
"noQualitySignals": "No quality signals",
"noQualitySignalsDesc": "Quality signal rows will appear after evidence reconciliation writes signal refs.",
"qualitySignalLabel": "Quality signal",
"signalOnly": "signal-only",
"triggerable": "triggerable",
"admissionShort": "admission:",
"inspectSignal": "Inspect signal",
"reviewEntrance": "Review and approval entry",
"reviewQueue": "Manual review queue",
"noReviewItems": "No approval items",
"noReviewItemsDesc": "Actionable candidates and inspect-only admission or validation blockers will appear here. The connected evidence DB has no pending review items.",
"inspectReview": "Inspect",
"approveForRecheck": "Approve recheck",
"inspectOnly": "Inspect only",
"inspectJob": "Inspect job",
"auditJobs": "Audit jobs",
"triggerJobs": "Trigger jobs",
"noJobs": "No trigger jobs",
"noJobsDesc": "Evolution trigger jobs will appear after evidence-backed evolution runs.",
"packets": "{{count}} packets",
"decisions": "{{count}} decisions",
"candidates": "Candidates",
"admissionQueue": "Admission queue",
"noCandidates": "No candidates",
"noCandidatesDesc": "Admission candidates will appear when a decision needs review.",
"recurrence": "recurrence {{count}}",
"targets": "{{count}} targets",
"requestRecheck": "Request review",
"recheckExecutedCommitted": "Candidate review ran and committed an evolution action.",
"recheckExecutedNoCommit": "Candidate review ran; no skill was committed. Check the generated job for the decision.",
"recheckQueuedNoEngine": "Candidate review was queued. This dashboard has no live evolution engine, so it cannot run now.",
"recheckQueued": "Candidate review was queued.",
"recheckNeedsRecovery": "Candidate review needs commit recovery. Check the linked action before retrying.",
"reject": "Reject",
"details": "Details",
"auditDetail": "Audit detail",
"noSelection": "No selection",
"noSelectionDesc": "Select a candidate, action, or evidence ref to inspect it.",
"selectedCandidate": "Selected candidate",
"selectedReviewItem": "Selected review item",
"selectedJob": "Selected job",
"selectedQualitySignal": "Selected quality signal",
"signalType": "Signal type:",
"subject": "Subject:",
"toolKey": "Tool key:",
"actionability": "Actionability:",
"notTriggerableReason": "Not triggerable:",
"job": "Job:",
"admission": "Admission:",
"hardFailures": "Hard failures:",
"warnings": "Warnings:",
"rawBackrefs": "Raw backrefs:",
"mergeKey": "Merge key:",
"reviewType": "Type:",
"reviewSummary": "Summary:",
"approvalState": "Approval state:",
"approvalAvailable": "Review can be approved",
"reviewNote": "Note:",
"trigger": "Trigger:",
"profile": "Profile:",
"watermark": "Watermark:",
"error": "Error:",
"linkedRecords": "Linked records",
"selectedPayload": "Selected payload",
"decision": "Decision:",
"action": "Action:",
"targetsLabel": "Targets:",
"blockedReason": "Blocked reason:",
"neededEvidence": "Needed evidence:",
"evidenceRefs": "Evidence refs",
"decisionPayload": "Decision payload",
"lastRecheckResult": "Last recheck result",
"selectedAction": "Selected action",
"skillId": "Skill ID:",
"targetDir": "Target dir:",
"stagingDir": "Staging dir:",
"changedFiles": "Changed files:",
"failure": "Failure:",
"refPreview": "Evidence ref preview"
},
"filter": {
"origin": "Origin:",
"allOrigins": "All Origins",
"tags": "Tags:",
"allTags": "All Tags"
},
"graph": {
"noGraphData": "No lineage graph data.",
"tooltipScore": "score: {{value}}",
"tooltipGeneration": "generation: {{value}}",
"tooltipOrigin": "origin: {{value}}"
},
"diffViewer": {
"noFiles": "No files in diff",
"old": "Old",
"new": "New"
},
"format": {
"noInstruction": "No instruction captured"
}
}

View file

@ -0,0 +1,34 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import en from './en.json';
import zh from './zh.json';
const STORAGE_KEY = 'openspace-lang';
function getSavedLanguage(): string {
try {
return localStorage.getItem(STORAGE_KEY) || 'en';
} catch {
return 'en';
}
}
i18n.use(initReactI18next).init({
resources: {
en: { translation: en },
zh: { translation: zh },
},
lng: getSavedLanguage(),
fallbackLng: 'en',
interpolation: { escapeValue: false },
});
i18n.on('languageChanged', (lng) => {
try {
localStorage.setItem(STORAGE_KEY, lng);
} catch {
// ignore
}
});
export default i18n;

View file

@ -0,0 +1,357 @@
{
"nav": {
"dashboard": "仪表盘",
"evolution": "演化审计",
"skills": "Skills",
"workflows": "工作流"
},
"common": {
"loading": "加载中…",
"yes": "是",
"no": "否",
"none": "无",
"unavailable": "不可用",
"unknown": "未知",
"close": "关闭",
"score": "评分",
"success": "成功率",
"active": "活跃",
"inactive": "未激活",
"noDescription": "暂无描述",
"steps_one": "{{count}} 步",
"steps_other": "{{count}} 步",
"agentActions_one": "{{count}} 个 Agent 操作",
"agentActions_other": "{{count}} 个 Agent 操作",
"tags": "+{{count}} 个标签"
},
"langSwitch": {
"label": "语言"
},
"errorBoundary": {
"title": "出现了错误",
"message": "发生了意外错误",
"goToDashboard": "返回仪表盘",
"details": "错误详情"
},
"dashboard": {
"title": "仪表盘",
"loadingDashboard": "正在加载仪表盘…",
"failedToLoad": "加载概览失败",
"dashboardUnavailable": "仪表盘不可用",
"totalSkills": "Skills 总数",
"activeHint": "活跃:{{count}}",
"avgSkillScore": "Skill 平均评分",
"avgScoreHint": "主要指标 = 有效率 × 100",
"workflowSessions": "工作流会话",
"localRepo": "本地仓库",
"workspace": "工作区",
"recordedUnder": "记录于{{location}}",
"workflowSuccess": "工作流成功率",
"avgSuccessHint": "平均会话成功率",
"health": "健康状态",
"runtimeSnapshot": "运行时快照",
"status": "状态",
"dbPath": "数据库路径",
"evidenceDbPath": "证据数据库路径",
"workflowCount": "工作流数量",
"builtFrontend": "前端构建",
"skillsSection": "Skills",
"topScoredSkills": "评分最高的 Skills",
"noSkillsYet": "暂无 Skills",
"noSkillsDesc": "先运行 OpenSpace 任务或将 Skills 同步到本地注册表中。",
"effective": "有效率 {{value}}",
"applied": "应用率 {{value}}",
"selections": "选择次数 {{count}}",
"workflowsSection": "工作流",
"recentSessions": "近期会话",
"noWorkflowSessions": "暂无工作流会话",
"noWorkflowDesc": "启用录制后执行任务,录制数据将在此显示。"
},
"skills": {
"title": "Skill 类别",
"searchPlaceholder": "按名称、ID、描述、标签或来源搜索",
"sortByScore": "按最佳评分排序",
"sortByUpdated": "按更新时间排序",
"sortByName": "按名称排序",
"skillClasses": "Skill 类别",
"versionsHint": "版本数:{{count}}",
"activeVersions": "活跃版本",
"withActivity": "有活动记录:{{count}}",
"avgBestScore": "平均最佳评分",
"bestNodeScoreHint": "每个类别的最佳节点评分",
"selections": "选择次数",
"completionsHint": "完成次数:{{count}}",
"loadingSkills": "正在加载 Skills…",
"failedToLoad": "加载 Skills 失败",
"noSkillsMatch": "没有匹配的 Skills",
"noSkillsMatchDesc": "尝试其他关键词,或执行任务以将新的 Skill 遥测数据写入 SQLite。",
"bestScore": "最佳评分",
"noClassDescription": "暂无类别描述",
"versions": "{{count}} 个版本",
"active": "{{count}} 个活跃",
"selectionsCount": "{{count}} 次选择"
},
"skillDetail": {
"loadingDetail": "正在加载 Skill 详情…",
"failedToLoad": "加载 Skill 类别失败",
"skillNotFound": "未找到 Skill",
"backToSkills": "← 返回 Skills",
"anchoredOn": "Skill 类别锚定于 {{id}}",
"skillClass": "Skill 类别",
"evolutionOverview": "演化概览",
"activeTip": "活跃端点",
"inactiveAnchor": "未激活锚点",
"bestVersionScore": "最佳版本评分",
"skillDirectory": "Skill 目录",
"latestVersionCreated": "最新版本创建时间",
"representativeVersion": "代表版本",
"representativeUpdate": "代表版本更新时间",
"versions": "版本数",
"maxGeneration": "最大世代 {{count}}",
"activeVersions": "活跃版本",
"originsCount": "来源数:{{count}}",
"averageScore": "平均评分",
"acrossAllVersions": "该谱系中所有版本的平均值",
"selections": "选择次数",
"representativeScore": "代表评分 {{score}}",
"evolutionGraph": "演化图",
"versionLineage": "版本谱系",
"loadingDrawer": "正在加载版本抽屉…",
"noLineageGraph": "暂无谱系图",
"noLineageGraphDesc": "该 Skill 尚无可供可视化的谱系数据。"
},
"workflows": {
"title": "录制会话",
"searchPlaceholder": "按任务名称或指令搜索",
"workflowSessions": "工作流会话",
"scannedFrom": "从 logs/recordings 和 logs/trajectories 扫描",
"averageSuccess": "平均成功率",
"meanSuccessRate": "各会话的平均成功率",
"loadingWorkflows": "正在加载工作流…",
"failedToLoad": "加载工作流失败",
"noSessions": "暂无工作流会话",
"noSessionsDesc": "启用录制运行 `openspace` 后刷新此页面。",
"more": "+{{count}} 更多"
},
"workflowDetail": {
"loadingDetail": "正在加载工作流详情…",
"failedToLoad": "加载工作流失败",
"workflowNotFound": "未找到工作流",
"backToWorkflows": "← 返回工作流列表",
"workflowDetail": "工作流详情",
"skillsSelected_one": "已选择 {{count}} 个 Skill",
"skillsSelected_other": "已选择 {{count}} 个 Skills",
"mergedEvent_one": "{{count}} 个合并事件",
"mergedEvent_other": "{{count}} 个合并事件",
"runDescription": "{{status}}运行,包含 {{iterations}} 和 {{actions}}。",
"started": "开始时间",
"duration": "持续时间",
"stepsLabel": "步骤",
"latestEvent": "最新事件",
"successRate": "成功率",
"successRateHint_one": "{{iterations}}中 {{count}} 次成功迭代",
"successRateHint_other": "{{iterations}}中 {{count}} 次成功迭代",
"iterations": "迭代次数",
"totalRuntime": "总运行时间 {{duration}}",
"activeBackends": "活跃后端",
"mostActive": "最活跃 {{backend}} · {{count}} 个事件",
"noRecordedActivity": "无已记录的工具活动",
"timelineEvents": "时间线事件",
"agentToolHint": "{{agentCount}} 个 Agent 操作 · {{toolCount}} 个工具事件",
"noTimelineData": "暂无时间线数据",
"noTimelineDesc": "此会话尚未包含轨迹或 Agent 操作记录。",
"rawEventJson": "原始事件 JSON",
"selection": "Skill 选择",
"selectedSkills": "已选 Skills",
"noSelectedSkills": "未选择 Skills",
"noSelectedSkillsDesc": "此次运行未选择或记录任何 Skills。",
"session": "会话",
"overview": "概览",
"taskId": "任务 ID",
"runtime": "运行时",
"window": "时间窗口",
"ended": "结束于 {{date}}",
"selectionMethod": "选择方法",
"notRecorded": "未记录",
"enabledBackends": "已启用后端",
"backendActivity": "后端活动",
"iteration_one": "{{count}} 次迭代",
"iteration_other": "{{count}} 次迭代",
"step_one": "{{count}} 步",
"step_other": "{{count}} 步",
"agentAction_one": "{{count}} 个 Agent 操作",
"agentAction_other": "{{count}} 个 Agent 操作",
"successfulIteration_one": "{{count}} 次成功迭代",
"successfulIteration_other": "{{count}} 次成功迭代"
},
"drawer": {
"skillVersion": "Skill 版本",
"openAsMain": "作为主视图打开",
"versionSummary": "版本摘要",
"noDescriptionAvailable": "此版本暂无描述信息。",
"gen": "第 {{generation}} 代",
"versionScore": "版本评分",
"metrics": "指标",
"executionQuality": "执行质量",
"effectiveRate": "有效率",
"completionRate": "完成率",
"appliedRate": "应用率",
"fallbackRate": "回退率",
"selectionsLabel": "选择次数",
"appliedLabel": "应用次数",
"completionsLabel": "完成次数",
"fallbacksLabel": "回退次数",
"versionMetadata": "版本元数据",
"origin": "来源:",
"generation": "世代:",
"visibility": "可见性:",
"created": "创建时间:",
"firstSeen": "首次出现:",
"lastUpdated": "最后更新:",
"skillPath": "Skill 路径:",
"skillDir": "Skill 目录:",
"parentIds": "父级 ID",
"evolutionAction": "演化 Action",
"provenanceRefs": "证据引用:",
"changeSummary": "变更摘要:",
"effectiveScore": "有效评分:",
"diff": "Diff",
"contentDiff": "内容 Diff",
"diffTooLarge": "Diff 过大",
"diffTooLargeDesc": "此版本的内容 Diff 过大,内联查看器已禁用。",
"diffUnavailable": "Diff 不可用",
"diffUnavailableDesc": "此版本有 Diff 数据,但无法解析为统一 Diff 格式。",
"noContentDiff": "无内容 Diff",
"noContentDiffDesc": "此版本没有存储的内容 Diff。",
"source": "源代码",
"skillMdPreview": "SKILL.md 预览",
"sourceUnavailable": "源代码不可用",
"sourceUnavailableDesc": "此版本指向的 SKILL.md 路径缺失或不可读。",
"analyses": "分析",
"recentAnalyses": "近期执行分析",
"noExecutionNote": "无执行备注",
"analysisCompleted": "已完成:{{value}} · 工具问题:{{toolIssues}} · 建议:{{suggestions}}",
"noAnalysesYet": "暂无分析",
"noAnalysesDesc": "执行分析将在已记录的任务运行持久化到 SQLite 后显示。"
},
"evolution": {
"title": "演化审计",
"refresh": "刷新",
"failedToLoad": "加载演化审计数据失败",
"failedToLoadSignals": "加载 quality signal 审计数据失败",
"failedToLoadDetails": "加载审计详情失败",
"failedToLoadRef": "加载证据引用失败",
"failedToUpdateCandidate": "更新候选项失败",
"openJobs": "开放 Jobs",
"visibleJobs": "当前显示 {{count}} 个 Jobs",
"failedJobs": "失败 Jobs",
"jobFilter": "Job 过滤:{{status}}",
"pendingCandidates": "待处理候选项",
"candidateFilter": "候选项过滤:{{status}}",
"pendingReviewItems": "审核项",
"reviewQueueHint": "可审批候选项和只读阻塞项",
"linkedActions": "关联 Actions",
"actionsFromJobs": "来自当前 Jobs 的 Action 引用",
"qualitySignalAudit": "Quality signal 审计",
"qualitySignals": "Quality signals",
"noQualitySignals": "暂无 quality signals",
"noQualitySignalsDesc": "证据 reconciler 写入 signal refs 后会显示 quality signal 行。",
"qualitySignalLabel": "Quality signal",
"signalOnly": "signal-only",
"triggerable": "可触发",
"admissionShort": "准入:",
"inspectSignal": "查看 signal",
"reviewEntrance": "审核与审批入口",
"reviewQueue": "人工审核队列",
"noReviewItems": "暂无待审批项",
"noReviewItemsDesc": "可审批的 candidate以及只读的 admission / validation 阻塞项会显示在这里。当前连接的 evidence DB 没有待处理项。",
"inspectReview": "查看",
"approveForRecheck": "批准复审",
"inspectOnly": "仅可查看",
"inspectJob": "查看 Job",
"auditJobs": "审计 Jobs",
"triggerJobs": "Trigger jobs",
"noJobs": "暂无 Trigger jobs",
"noJobsDesc": "基于证据链的演化运行后会显示 Trigger jobs。",
"packets": "{{count}} 个 packets",
"decisions": "{{count}} 个 decisions",
"candidates": "候选项",
"admissionQueue": "准入队列",
"noCandidates": "暂无候选项",
"noCandidatesDesc": "当某个决策需要审阅时,准入候选项会显示在这里。",
"recurrence": "出现 {{count}} 次",
"targets": "{{count}} 个目标",
"requestRecheck": "请求复审",
"recheckExecutedCommitted": "候选项复审已执行,并提交了一个演化 action。",
"recheckExecutedNoCommit": "候选项复审已执行,但没有提交 skill。请查看生成的 job 决策。",
"recheckQueuedNoEngine": "候选项复审已排队。当前 dashboard 没有 live evolution engine不能立即执行。",
"recheckQueued": "候选项复审已排队。",
"recheckNeedsRecovery": "候选项复审需要提交恢复。重试前请先检查关联 action。",
"reject": "拒绝",
"details": "详情",
"auditDetail": "审计详情",
"noSelection": "未选择",
"noSelectionDesc": "选择候选项、Action 或证据引用以查看详情。",
"selectedCandidate": "已选候选项",
"selectedReviewItem": "已选审核项",
"selectedJob": "已选 Job",
"selectedQualitySignal": "已选 quality signal",
"signalType": "Signal 类型:",
"subject": "Subject",
"toolKey": "Tool key",
"actionability": "可执行性:",
"notTriggerableReason": "不可触发原因:",
"job": "Job",
"admission": "准入:",
"hardFailures": "Hard failures",
"warnings": "Warnings",
"rawBackrefs": "Raw backrefs",
"mergeKey": "Merge key",
"reviewType": "类型:",
"reviewSummary": "摘要:",
"approvalState": "审批状态:",
"approvalAvailable": "可批准复审",
"reviewNote": "说明:",
"trigger": "Trigger",
"profile": "Profile",
"watermark": "Watermark",
"error": "Error",
"linkedRecords": "关联记录",
"selectedPayload": "已选 payload",
"decision": "Decision",
"action": "Action",
"targetsLabel": "目标:",
"blockedReason": "阻塞原因:",
"neededEvidence": "需要的证据:",
"evidenceRefs": "证据引用",
"decisionPayload": "Decision payload",
"lastRecheckResult": "最近复审结果",
"selectedAction": "已选 Action",
"skillId": "Skill ID",
"targetDir": "目标目录:",
"stagingDir": "Staging 目录:",
"changedFiles": "变更文件:",
"failure": "失败原因:",
"refPreview": "证据引用预览"
},
"filter": {
"origin": "来源:",
"allOrigins": "所有来源",
"tags": "标签:",
"allTags": "所有标签"
},
"graph": {
"noGraphData": "暂无谱系图数据。",
"tooltipScore": "评分:{{value}}",
"tooltipGeneration": "Generation{{value}}",
"tooltipOrigin": "Origin{{value}}"
},
"diffViewer": {
"noFiles": "Diff 中无文件",
"old": "旧版",
"new": "新版"
},
"format": {
"noInstruction": "未捕获到指令"
}
}

View file

@ -0,0 +1,644 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--color-bg-page: #FAF9F5;
--color-surface: #FFFFFF;
--color-primary: #D97757;
--color-ink: #141413;
--color-muted: #5E5D59;
--color-accent: #0D9488;
--color-danger: #DC2626;
--color-oxide: #B85C50;
--color-teal: #78B4AC;
--color-gold: #FFDB58;
--color-red: #9B3833;
--color-diff-add: #C1D5CD;
--color-diff-del: #E5AC93;
--color-border: #E8E6DC;
--color-border-dark: #D1CFC5;
--color-mid-gray: #B0AEA5;
--color-blue: #6A9BCC;
--color-green: #788C5D;
--color-paper: #F5F4ED;
--color-warm-gray: #F0EEE6;
--color-sage: #BCD2CB;
--color-lavender: #CBCADB;
--color-sand: #E3DACC;
--color-selection: #ECBBAB;
--radius: 8px;
--radius-chip: 999px;
--radius-card: 32px;
--radius-card-sm: 18px;
--font-serif: 'Neuton', 'Noto Serif CJK SC', 'Songti SC', 'SimSun', Georgia, serif;
--font-sans: 'Cabin', system-ui, -apple-system, sans-serif;
--font-mono: ui-monospace, 'SF Mono', Menlo, Monaco, 'Cascadia Code', 'Courier New', monospace;
--shadow-hard: 4px 4px 0px 0px rgba(74, 59, 42, 0.1);
--shadow-hard-sm: 2px 2px 0px 0px rgba(74, 59, 42, 0.1);
--shadow-button: 3px 3px 0px 0px var(--color-ink);
--shadow-soft:
0 1px 2px rgba(74, 59, 42, 0.04),
0 4px 12px rgba(74, 59, 42, 0.06),
0 8px 24px rgba(74, 59, 42, 0.04);
--bg-scanlines: linear-gradient(
to bottom,
transparent 50%,
rgba(74, 59, 42, 0.03) 50%
);
--bg-vignette: radial-gradient(
circle at center,
transparent 60%,
rgba(74, 59, 42, 0.12) 120%
);
--bg-noise: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)' opacity='0.15'/%3E%3C/svg%3E");
}
* {
box-sizing: border-box;
}
::selection {
background-color: #ECBBAB;
}
body {
margin: 0;
background-color: var(--color-bg-page);
color: var(--color-ink);
font-family: var(--font-mono);
-webkit-font-smoothing: antialiased;
}
html {
scrollbar-gutter: stable;
}
html.drawer-open,
body.drawer-open {
overflow: hidden;
overscroll-behavior: none;
}
h1{
font-family: var(--font-sans);
}
button {
font-family: inherit;
cursor: pointer;
border-radius: var(--radius);
}
input, select, textarea {
font-family: inherit;
border-radius: var(--radius);
border: 2px solid var(--color-border);
background-color: #FFFFFF;
}
input[type="radio"] {
appearance: none;
width: 16px;
height: 16px;
border: 2px solid var(--color-border-dark);
border-radius: 50%;
background-color: #FFFFFF;
cursor: pointer;
transition: all 0.2s ease;
}
input[type="radio"]:checked {
border-color: #BCD2CB;
background-color: #BCD2CB;
box-shadow: 0 0 8px 2px rgba(188, 210, 203, 0.6);
}
input[type="checkbox"] {
appearance: none;
width: 16px;
height: 16px;
border: 2px solid var(--color-border-dark);
border-radius: 4px;
background-color: #FFFFFF;
cursor: pointer;
transition: all 0.2s ease;
}
input[type="checkbox"]:checked {
border-color: #BCD2CB;
background-color: #BCD2CB;
box-shadow: 0 0 8px 2px rgba(188, 210, 203, 0.6);
}
}
@layer components {
.field-surface {
border: 2px solid var(--color-border);
border-radius: var(--radius);
background-color: var(--color-surface);
}
.panel-surface {
border: none;
border-radius: var(--radius-card);
background-color: var(--color-surface);
}
.metrics-row {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0;
}
.metric-card {
border: none;
border-radius: 0;
background: transparent;
padding: 1.5rem 1.625rem;
display: flex;
flex-direction: column;
gap: 0;
position: relative;
}
.metric-card + .metric-card::before {
content: '';
position: absolute;
left: 0;
top: 12%;
height: 76%;
width: 1px;
background-color: var(--color-border-dark);
}
.panel-subtle {
border: 1px solid var(--color-border-dark);
border-radius: var(--radius);
}
.record-card {
border: none;
border-radius: var(--radius-card);
transition:
transform 0.2s ease,
box-shadow 0.2s ease,
border-color 0.2s ease;
}
.record-card:hover {
transform: scale(1.005);
box-shadow:
0 1px 4px rgba(74, 59, 42, 0.06),
0 3px 10px rgba(74, 59, 42, 0.04);
}
.panel-surface .record-card {
border: 1px solid transparent;
border-radius: 16px;
background-color: transparent;
animation: none 0s ease 0s 1 normal none running;
box-shadow: 0 0 0 0 transparent;
transition:
transform 0.2s ease,
border-color 0.2s ease,
background-color 0.2s ease,
box-shadow 0.2s ease;
}
.panel-surface .record-card:hover {
transform: scale(1.005);
border-color: var(--color-border-dark);
background-color: var(--color-bg-page);
box-shadow:
0 2px 6px rgba(74, 59, 42, 0.08),
0 6px 20px rgba(74, 59, 42, 0.06);
}
.btn-primary {
@apply bg-primary text-bg-page px-4 py-2 font-bold btn-press disabled:opacity-50;
box-shadow: 2px 3px 0px 0px #1b1b1a;
}
.btn-primary:active {
box-shadow: 1px 1px 0px 0px #141413;
}
.btn-outline {
@apply border-2 px-3 py-1 hover:bg-surface btn-press;
border-color: var(--color-border);
}
.btn-outline-ink {
@apply border-2 px-3 py-1 btn-press;
border-color: var(--color-border-dark);
}
.btn-outline-ink:hover {
background-color: var(--color-bg-page);
}
.chip {
display: inline-flex;
align-items: center;
gap: 0.375rem;
border: 1px solid var(--chip-border, var(--color-border));
border-radius: 7.5px;
background-color: var(--chip-bg, var(--color-surface));
padding: 0.4rem 0.75rem;
line-height: 1;
color: var(--chip-text, var(--color-muted));
}
.tag {
border-radius: var(--radius-chip);
border: 1.5px solid var(--color-border);
}
/* === shared global templates === */
.card-panel {
border: 1px solid var(--color-border);
border-radius: var(--radius-card);
background-color: var(--color-surface);
}
.card-soft {
border: none;
border-radius: var(--radius-card-sm);
background-color: var(--color-surface);
box-shadow: var(--shadow-soft);
}
.tab-pill {
display: inline-flex;
align-items: center;
gap: 0.5rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-chip);
background-color: var(--color-surface);
padding: 0.5rem 1rem;
font-size: 0.875rem;
line-height: 1;
color: var(--color-muted);
cursor: pointer;
transition: all 0.2s ease;
}
.tab-pill:hover {
border-color: var(--color-border-dark);
background-color: var(--color-paper);
}
.tab-pill.is-active {
border-color: var(--color-ink);
background-color: var(--color-ink);
color: var(--color-bg-page);
}
.model-card {
border: 1px solid var(--color-border);
border-radius: var(--radius-card);
background-color: var(--color-surface);
padding: 2rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.model-card-title {
font-family: var(--font-sans);
font-size: 1.5rem;
font-weight: 600;
letter-spacing: -0.02em;
color: var(--color-ink);
}
.model-card-subtitle {
font-family: var(--font-serif);
font-size: 0.9375rem;
line-height: 1.6;
color: var(--color-muted);
}
.model-card-features {
font-family: var(--font-serif);
font-size: 0.875rem;
color: var(--color-muted);
}
.news-card {
border: 1px solid var(--color-border);
border-radius: var(--radius-card);
background-color: var(--color-surface);
overflow: hidden;
display: flex;
flex-direction: column;
transition: border-color 0.2s ease;
}
.news-card:hover {
border-color: var(--color-border-dark);
}
.news-card-body {
padding: 1.25rem 1.5rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.news-card-title {
font-family: var(--font-sans);
font-size: 1.125rem;
font-weight: 600;
color: var(--color-ink);
}
.news-card-label {
font-size: 0.75rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--color-muted);
}
.feature-card {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.feature-card-title {
font-family: var(--font-sans);
font-size: 1.25rem;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--color-ink);
}
.feature-card-desc {
font-family: var(--font-serif);
font-size: 1rem;
line-height: 1.6;
color: var(--color-muted);
}
.prompt-card {
border: 1px solid var(--color-border);
border-radius: var(--radius-card);
background-color: var(--color-surface);
overflow: hidden;
}
.prompt-card-header {
padding: 1.25rem 1.5rem;
border-bottom: 1px solid var(--color-border);
font-size: 0.875rem;
color: var(--color-muted);
}
.prompt-card-body {
padding: 1.5rem;
}
.kicker {
font-size: 11px;
line-height: 1.4;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--color-muted);
}
/* === end shared global templates === */
.workflow-detail-page {
font-family: var(--font-sans);
}
.workflow-detail-page h1,
.workflow-detail-page h2,
.workflow-detail-page h3 {
font-family: var(--font-sans);
}
.workflow-detail-page .workflow-copy {
font-family: var(--font-serif);
}
.workflow-detail-page .workflow-kicker {
font-size: 11px;
line-height: 1.4;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--color-muted);
}
.workflow-detail-page .workflow-hero {
border: none;
border-radius: 0;
background: transparent;
}
.workflow-detail-page .workflow-panel {
border: 1px solid var(--color-border);
border-radius: var(--radius-card);
background-color: var(--color-surface);
}
.workflow-detail-page .workflow-soft-card {
border: none;
border-radius: var(--radius-card-sm);
background-color: var(--color-surface);
box-shadow: var(--shadow-soft);
}
.workflow-detail-page .workflow-metrics-row {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0;
}
.workflow-detail-page .workflow-metric-card {
border: none;
border-radius: 0;
background: transparent;
padding: 1.5rem 1.625rem;
display: flex;
flex-direction: column;
gap: 0;
position: relative;
}
.workflow-detail-page .workflow-metric-card + .workflow-metric-card::before {
content: '';
position: absolute;
left: 0;
top: 12%;
height: 76%;
width: 1px;
background-color: var(--color-border-dark);
}
.workflow-detail-page .workflow-metric-card .workflow-kicker {
font-size: 0.875rem;
line-height: 1.5;
letter-spacing: 0.08em;
}
.workflow-detail-page .workflow-metric-header {
margin-bottom: 0.875rem;
}
.workflow-detail-page .workflow-metric-value {
font-size: 2.75rem;
font-weight: 600;
line-height: 1;
letter-spacing: -0.04em;
color: var(--color-ink);
}
.workflow-detail-page .workflow-metric-divider {
width: 2rem;
height: 1px;
background-color: var(--color-border-dark);
margin: 1rem 0;
}
.workflow-detail-page .workflow-metric-hint {
font-size: 0.875rem;
line-height: 1.5;
color: var(--color-muted);
font-family: var(--font-serif);
}
.workflow-detail-page .workflow-chip {
display: inline-flex;
align-items: center;
gap: 0.375rem;
border: 1px solid var(--workflow-chip-border, var(--color-border));
border-radius: 7.5px;
background-color: var(--workflow-chip-bg, var(--color-surface));
padding: 0.4rem 0.75rem;
line-height: 1;
color: var(--workflow-chip-text, var(--color-muted));
}
.workflow-detail-page .workflow-json {
font-family: var(--font-mono);
}
.workflow-detail-page .workflow-inline-label {
margin-right: 0.45rem;
font-size: 11px;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--color-muted);
white-space: nowrap;
}
.workflow-detail-page .workflow-accordion-item {
border-bottom: 1px solid var(--color-border);
padding: 1rem 0;
}
.workflow-detail-page .workflow-accordion-item:last-child {
border-bottom: none;
}
.workflow-detail-page .workflow-expand-summary {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.9rem;
height: 1.9rem;
flex-shrink: 0;
border: 1px solid var(--color-border);
border-radius: 999px;
background-color: var(--color-surface);
color: var(--color-muted);
transition: border-color 0.3s ease, background-color 0.3s ease;
}
.workflow-detail-page .workflow-expand.is-opened .workflow-expand-summary {
border-color: var(--color-border-dark);
background-color: var(--color-bg-page);
}
.workflow-detail-page .workflow-expand-icon {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
.workflow-detail-page .workflow-toggle-line {
position: absolute;
width: 50%;
height: 1.5px;
background-color: currentColor;
}
.workflow-detail-page .workflow-toggle-line.is-2 {
transform: rotate(90deg);
transition: transform 500ms cubic-bezier(0.16, 1, 0.3, 1);
}
.workflow-detail-page .workflow-expand.is-opened .workflow-toggle-line.is-2 {
transform: rotate(0deg);
}
.workflow-detail-page .workflow-accordion-content {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 0.35s cubic-bezier(0.16, 1, 0.3, 1);
}
.workflow-detail-page .workflow-expand.is-opened .workflow-accordion-content {
grid-template-rows: 1fr;
}
}
@layer utilities {
.btn-press {
transition: all 0.1s;
}
.btn-press:active {
transform: translate(2px, 2px);
box-shadow: 1px 1px 0px 0px var(--color-ink);
}
@keyframes slide-up {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-slide-up {
animation: slide-up 0.3s ease-out;
}
@keyframes drawer-slide-in {
from {
opacity: 0;
transform: translateX(100%);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes drawer-slide-out {
from {
opacity: 1;
transform: translateX(0);
}
to {
opacity: 0;
transform: translateX(100%);
}
}
}
.app-scroll-region {
scrollbar-gutter: stable both-edges;
}
.drawer-scroll {
scrollbar-gutter: stable;
}
.drawer-scroll-region {
scrollbar-gutter: stable both-edges;
}

View file

@ -0,0 +1,76 @@
import { NavLink, Outlet } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
const linkClass = ({ isActive }: { isActive: boolean }) =>
isActive
? 'font-bold text-primary underline decoration-2 underline-offset-4'
: 'hover:text-primary';
const envHost = import.meta.env.VITE_HOST || '127.0.0.1';
const envPort = import.meta.env.VITE_PORT || '3888';
const apiProxyTarget = import.meta.env.VITE_API_PROXY_TARGET || 'http://127.0.0.1:7788';
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL || '/api/v1';
const isDev = import.meta.env.DEV;
function hostLabel(value: string) {
try {
return new URL(value).host;
} catch {
return value.replace(/^https?:\/\//, '').replace(/\/.*$/, '');
}
}
const viteHost = envHost === '0.0.0.0' ? '127.0.0.1' : envHost;
const viteLabel = `${viteHost}:${envPort}`;
const apiLabel = apiBaseUrl.startsWith('http')
? hostLabel(apiBaseUrl)
: (isDev ? hostLabel(apiProxyTarget) : window.location.host);
export default function MainLayout() {
const { t, i18n } = useTranslation();
const toggleLang = () => {
i18n.changeLanguage(i18n.language === 'zh' ? 'en' : 'zh');
};
return (
<div className="h-screen min-w-[1180px] relative flex flex-col overflow-x-auto overflow-y-hidden bg-bg-page text-ink">
<nav className="relative z-10 flex justify-between items-center px-4 py-3 border-b border-[color:var(--color-border)] bg-bg-page">
<div className="flex items-center gap-8">
<div className="font-bold text-3xl tracking-tighter font-serif">OpenSpace</div>
<div className="flex gap-4 text-sm">
<NavLink to="/dashboard" className={linkClass}>
{t('nav.dashboard')}
</NavLink>
<NavLink to="/evolution" className={linkClass}>
{t('nav.evolution')}
</NavLink>
<NavLink to="/skills" className={linkClass}>
{t('nav.skills')}
</NavLink>
<NavLink to="/workflows" className={linkClass}>
{t('nav.workflows')}
</NavLink>
</div>
</div>
<div className="flex items-center gap-4">
<button
type="button"
onClick={toggleLang}
className="px-2.5 py-1 text-xs border border-[color:var(--color-border-dark)] rounded hover:bg-[color:var(--color-surface)] transition-colors cursor-pointer bg-transparent text-ink"
>
{i18n.language === 'zh' ? 'EN' : '中文'}
</button>
<div className="text-xs text-muted">
API: {apiLabel}
{isDev ? ` · Vite: ${viteLabel}` : ''}
</div>
</div>
</nav>
<main className="app-scroll-region relative z-10 min-h-0 flex-1 overflow-auto">
<Outlet />
</main>
</div>
);
}

View file

@ -0,0 +1,14 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import './i18n';
import './index.css';
import App from './App';
import { ErrorBoundary } from './components/ErrorBoundary';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<ErrorBoundary>
<App />
</ErrorBoundary>
</StrictMode>,
);

View file

@ -0,0 +1,142 @@
import { Link } from 'react-router-dom';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { overviewApi, type OverviewResponse } from '../api';
import MetricCard from '../components/MetricCard';
import EmptyState from '../components/EmptyState';
import { formatDate, formatInstruction, formatPercent, truncate } from '../utils/format';
export default function DashboardPage() {
const { t } = useTranslation();
const [data, setData] = useState<OverviewResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
const load = async () => {
setLoading(true);
setError(null);
try {
const overview = await overviewApi.getOverview();
if (!cancelled) {
setData(overview);
}
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : t('dashboard.failedToLoad'));
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
};
void load();
return () => {
cancelled = true;
};
}, [t]);
if (loading) {
return <div className="p-6 text-sm text-muted">{t('dashboard.loadingDashboard')}</div>;
}
if (error || !data) {
return <div className="p-6 text-sm text-danger">{error ?? t('dashboard.dashboardUnavailable')}</div>;
}
return (
<div className="p-6 space-y-6">
<h1 className="text-3xl font-bold font-serif">{t('dashboard.title')}</h1>
<section className="metrics-row">
<MetricCard label={t('dashboard.totalSkills')} value={data.skills.summary.total_skills_all} hint={t('dashboard.activeHint', { count: data.skills.summary.total_skills })} />
<MetricCard label={t('dashboard.avgSkillScore')} value={data.skills.average_score.toFixed(1)} hint={t('dashboard.avgScoreHint')} />
<MetricCard label={t('dashboard.workflowSessions')} value={data.workflows.total} hint={t('dashboard.recordedUnder', { location: data.health.db_path.includes('.openspace') ? t('dashboard.localRepo') : t('dashboard.workspace') })} />
<MetricCard label={t('dashboard.workflowSuccess')} value={`${data.workflows.average_success_rate.toFixed(1)}%`} hint={t('dashboard.avgSuccessHint')} />
</section>
<section>
<div className="panel-surface p-5 space-y-4">
<div>
<div className="text-xs uppercase tracking-[0.16em] text-muted">{t('dashboard.health')}</div>
<h2 className="text-2xl font-bold font-serif mt-1">{t('dashboard.runtimeSnapshot')}</h2>
</div>
<div className="space-y-3 text-sm">
<div className="flex items-center justify-between"><span className="text-muted">{t('dashboard.status')}</span><span>{data.health.status}</span></div>
<div className="flex items-center justify-between"><span className="text-muted">{t('dashboard.dbPath')}</span><span className="text-right break-all">{data.health.db_path}</span></div>
<div className="flex items-center justify-between gap-4"><span className="text-muted">{t('dashboard.evidenceDbPath')}</span><span className="text-right break-all">{data.health.evidence_db_path}</span></div>
<div className="flex items-center justify-between"><span className="text-muted">{t('dashboard.workflowCount')}</span><span>{data.health.workflow_count}</span></div>
<div className="flex items-center justify-between"><span className="text-muted">{t('dashboard.builtFrontend')}</span><span>{data.health.frontend_dist_exists ? t('common.yes') : t('common.no')}</span></div>
</div>
</div>
</section>
<section className="grid grid-cols-2 gap-6">
<div className="panel-surface p-5 space-y-4">
<div>
<div className="text-xs uppercase tracking-[0.16em] text-muted">{t('dashboard.skillsSection')}</div>
<h2 className="text-2xl font-bold font-serif mt-1">{t('dashboard.topScoredSkills')}</h2>
</div>
{data.skills.top.length === 0 ? (
<EmptyState title={t('dashboard.noSkillsYet')} description={t('dashboard.noSkillsDesc')} />
) : (
<div className="space-y-3">
{data.skills.top.map((skill) => (
<Link key={skill.skill_id} to={`/skills/${encodeURIComponent(skill.skill_id)}`} className="record-card block p-4">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 space-y-1">
<div className="font-bold truncate">{skill.name}</div>
<div className="text-sm text-muted">{truncate(skill.description || t('common.noDescription'), 110)}</div>
</div>
<div className="text-right shrink-0">
<div className="text-2xl font-bold font-serif">{skill.score.toFixed(1)}</div>
<div className="text-xs text-muted">{t('common.score')}</div>
</div>
</div>
<div className="mt-3 flex gap-3 text-xs text-muted">
<span>{t('dashboard.effective', { value: formatPercent(skill.effective_rate) })}</span>
<span>{t('dashboard.applied', { value: formatPercent(skill.applied_rate) })}</span>
<span>{t('dashboard.selections', { count: skill.total_selections })}</span>
</div>
</Link>
))}
</div>
)}
</div>
<div className="panel-surface p-5 space-y-4">
<div>
<div className="text-xs uppercase tracking-[0.16em] text-muted">{t('dashboard.workflowsSection')}</div>
<h2 className="text-2xl font-bold font-serif mt-1">{t('dashboard.recentSessions')}</h2>
</div>
{data.workflows.recent.length === 0 ? (
<EmptyState title={t('dashboard.noWorkflowSessions')} description={t('dashboard.noWorkflowDesc')} />
) : (
<div className="space-y-3">
{data.workflows.recent.map((workflow) => (
<Link key={workflow.id} to={`/workflows/${encodeURIComponent(workflow.id)}`} className="record-card block p-4">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 space-y-1">
<div className="font-bold truncate">{workflow.task_name}</div>
<div className="text-sm text-muted line-clamp-2">{formatInstruction(workflow.instruction, 160, t('format.noInstruction'))}</div>
</div>
<div className="text-right shrink-0">
<div className="text-lg font-bold font-serif">{(workflow.success_rate * 100).toFixed(1)}%</div>
<div className="text-xs text-muted">{t('common.success')}</div>
</div>
</div>
<div className="mt-3 flex gap-3 text-xs text-muted">
<span>{t('common.steps', { count: workflow.total_steps })}</span>
<span>{t('common.agentActions', { count: workflow.agent_action_count })}</span>
<span>{formatDate(workflow.start_time)}</span>
</div>
</Link>
))}
</div>
)}
</div>
</section>
</div>
);
}

View file

@ -0,0 +1,738 @@
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
evolutionApi,
type CandidateRecheckResult,
type EvidenceRefPreview,
type EvolutionAction,
type EvolutionCandidate,
type EvolutionJob,
type EvolutionReviewItem,
type QualitySignalAuditRow,
} from '../api';
import EmptyState from '../components/EmptyState';
import MetricCard from '../components/MetricCard';
import { formatDate, truncate } from '../utils/format';
type JsonRecord = Record<string, unknown>;
const JOB_STATUSES = ['all', 'pending', 'running', 'failed_retryable', 'failed', 'completed'];
const CANDIDATE_STATUSES = ['pending', 'all', 'promoted', 'rejected', 'superseded'];
function statusTone(status: string) {
if (['completed', 'committed', 'committed_reconciled', 'promoted'].includes(status)) {
return 'text-accent';
}
if (['failed', 'failed_needs_review', 'rejected'].includes(status)) {
return 'text-danger';
}
if (['failed_retryable', 'running', 'committing'].includes(status)) {
return 'text-primary';
}
return 'text-muted';
}
function JsonPreview({ value }: { value: unknown }) {
return (
<pre className="field-surface max-h-[320px] overflow-auto p-3 text-xs whitespace-pre-wrap">
{JSON.stringify(value, null, 2)}
</pre>
);
}
function candidateRecheckMessageKey(result: CandidateRecheckResult) {
switch (result.recheck_status) {
case 'needs_recovery':
return 'evolution.recheckNeedsRecovery';
case 'executed_committed':
return 'evolution.recheckExecutedCommitted';
case 'executed_no_commit':
return 'evolution.recheckExecutedNoCommit';
case 'queued_no_engine':
return 'evolution.recheckQueuedNoEngine';
case 'queued_recheck':
default:
return 'evolution.recheckQueued';
}
}
export default function EvolutionPage() {
const { t } = useTranslation();
const [jobs, setJobs] = useState<EvolutionJob[]>([]);
const [candidates, setCandidates] = useState<EvolutionCandidate[]>([]);
const [reviewItems, setReviewItems] = useState<EvolutionReviewItem[]>([]);
const [qualitySignals, setQualitySignals] = useState<QualitySignalAuditRow[]>([]);
const [jobStatus, setJobStatus] = useState('all');
const [candidateStatus, setCandidateStatus] = useState('pending');
const [selectedCandidate, setSelectedCandidate] = useState<EvolutionCandidate | null>(null);
const [selectedReviewItem, setSelectedReviewItem] = useState<EvolutionReviewItem | null>(null);
const [selectedJob, setSelectedJob] = useState<EvolutionJob | null>(null);
const [selectedQualitySignal, setSelectedQualitySignal] = useState<QualitySignalAuditRow | null>(null);
const [selectedDecision, setSelectedDecision] = useState<JsonRecord | null>(null);
const [selectedAction, setSelectedAction] = useState<EvolutionAction | null>(null);
const [selectedRef, setSelectedRef] = useState<EvidenceRefPreview | null>(null);
const [loading, setLoading] = useState(true);
const [detailLoading, setDetailLoading] = useState(false);
const [busyCandidateId, setBusyCandidateId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [qualitySignalError, setQualitySignalError] = useState<string | null>(null);
const [detailError, setDetailError] = useState<string | null>(null);
const [candidateNotice, setCandidateNotice] = useState<string | null>(null);
const reload = async () => {
setLoading(true);
setError(null);
setQualitySignalError(null);
try {
const [nextJobs, nextCandidates, nextReviewItems] = await Promise.all([
evolutionApi.listJobs({ status: jobStatus, limit: 100 }),
evolutionApi.listCandidates({ status: candidateStatus, limit: 100 }),
evolutionApi.listReviewItems({ limit: 100 }),
]);
setJobs(nextJobs);
setCandidates(nextCandidates);
setReviewItems(nextReviewItems);
} catch (err) {
setError(err instanceof Error ? err.message : t('evolution.failedToLoad'));
} finally {
setLoading(false);
}
try {
setQualitySignals(await evolutionApi.listQualitySignals({ limit: 100 }));
} catch (err) {
setQualitySignals([]);
setQualitySignalError(err instanceof Error ? err.message : t('evolution.failedToLoadSignals'));
}
};
useEffect(() => {
void reload();
}, [candidateStatus, jobStatus]);
const summary = useMemo(() => {
const openJobs = jobs.filter((job) => ['pending', 'running', 'failed_retryable'].includes(job.status)).length;
const failedJobs = jobs.filter((job) => job.status.startsWith('failed')).length;
const pendingCandidates = candidates.filter((candidate) => candidate.status === 'pending').length;
const pendingReviews = reviewItems.length;
return { openJobs, failedJobs, pendingCandidates, pendingReviews };
}, [candidates, jobs, reviewItems]);
const loadCandidate = async (candidate: EvolutionCandidate) => {
setDetailLoading(true);
setDetailError(null);
setCandidateNotice(null);
setSelectedReviewItem(null);
setSelectedJob(null);
setSelectedQualitySignal(null);
setSelectedRef(null);
setSelectedAction(null);
try {
const [detail, decision] = await Promise.all([
evolutionApi.getCandidate(candidate.candidate_id),
evolutionApi.getDecision(candidate.decision_id),
]);
setSelectedCandidate(detail);
setSelectedDecision(decision);
} catch (err) {
setDetailError(err instanceof Error ? err.message : t('evolution.failedToLoadDetails'));
} finally {
setDetailLoading(false);
}
};
const loadAction = async (actionId: string) => {
setDetailLoading(true);
setDetailError(null);
setCandidateNotice(null);
setSelectedReviewItem(null);
setSelectedJob(null);
setSelectedQualitySignal(null);
setSelectedCandidate(null);
setSelectedDecision(null);
setSelectedRef(null);
try {
setSelectedAction(await evolutionApi.getAction(actionId));
} catch (err) {
setDetailError(err instanceof Error ? err.message : t('evolution.failedToLoadDetails'));
} finally {
setDetailLoading(false);
}
};
const loadRefPreview = async (refId: string) => {
setDetailLoading(true);
setDetailError(null);
setCandidateNotice(null);
try {
setSelectedRef(await evolutionApi.previewEvidenceRef(refId, 2000));
} catch (err) {
setDetailError(err instanceof Error ? err.message : t('evolution.failedToLoadRef'));
} finally {
setDetailLoading(false);
}
};
const loadJob = async (job: EvolutionJob) => {
setDetailLoading(true);
setDetailError(null);
setCandidateNotice(null);
setSelectedCandidate(null);
setSelectedReviewItem(null);
setSelectedQualitySignal(null);
setSelectedAction(null);
setSelectedRef(null);
try {
const detail = await evolutionApi.getJob(job.job_id);
setSelectedJob(detail);
setSelectedDecision(detail.decision_ids[0] ? await evolutionApi.getDecision(detail.decision_ids[0]) : null);
} catch (err) {
setDetailError(err instanceof Error ? err.message : t('evolution.failedToLoadDetails'));
} finally {
setDetailLoading(false);
}
};
const loadReviewItem = async (item: EvolutionReviewItem) => {
if (item.item_type === 'candidate' && item.candidate_id) {
const candidate = candidates.find((candidateItem) => candidateItem.candidate_id === item.candidate_id);
if (candidate) {
await loadCandidate(candidate);
setSelectedReviewItem(item);
return;
}
}
setDetailLoading(true);
setDetailError(null);
setCandidateNotice(null);
setSelectedCandidate(null);
setSelectedJob(null);
setSelectedQualitySignal(null);
setSelectedAction(null);
setSelectedRef(null);
setSelectedReviewItem(item);
try {
setSelectedDecision(item.decision_id ? await evolutionApi.getDecision(item.decision_id) : null);
} catch (err) {
setDetailError(err instanceof Error ? err.message : t('evolution.failedToLoadDetails'));
} finally {
setDetailLoading(false);
}
};
const selectQualitySignal = (signal: QualitySignalAuditRow) => {
setDetailLoading(false);
setDetailError(null);
setCandidateNotice(null);
setSelectedCandidate(null);
setSelectedReviewItem(null);
setSelectedJob(null);
setSelectedDecision(null);
setSelectedAction(null);
setSelectedRef(null);
setSelectedQualitySignal(signal);
};
const rejectCandidate = async (candidate: EvolutionCandidate) => {
setBusyCandidateId(candidate.candidate_id);
setDetailError(null);
setCandidateNotice(null);
try {
const updated = await evolutionApi.rejectCandidate(candidate.candidate_id, 'manual reject from dashboard');
setSelectedCandidate(updated);
setSelectedReviewItem(null);
await reload();
} catch (err) {
setDetailError(err instanceof Error ? err.message : t('evolution.failedToUpdateCandidate'));
} finally {
setBusyCandidateId(null);
}
};
const requestCandidateRecheck = async (candidate: EvolutionCandidate) => {
setBusyCandidateId(candidate.candidate_id);
setDetailError(null);
try {
const result = await evolutionApi.requestCandidateRecheck(candidate.candidate_id, true);
setCandidateNotice(t(candidateRecheckMessageKey(result), { jobId: result.job_id }));
await reload();
const updated = await evolutionApi.getCandidate(candidate.candidate_id);
setSelectedCandidate(updated);
} catch (err) {
setDetailError(err instanceof Error ? err.message : t('evolution.failedToUpdateCandidate'));
} finally {
setBusyCandidateId(null);
}
};
return (
<div className="p-6 space-y-6">
<div className="flex items-end justify-between gap-4">
<div>
<h1 className="text-3xl font-bold font-serif">{t('evolution.title')}</h1>
</div>
<button type="button" className="btn-outline-ink text-sm" onClick={() => void reload()}>
{t('evolution.refresh')}
</button>
</div>
<section className="metrics-row">
<MetricCard label={t('evolution.openJobs')} value={summary.openJobs} hint={t('evolution.visibleJobs', { count: jobs.length })} />
<MetricCard label={t('evolution.failedJobs')} value={summary.failedJobs} hint={t('evolution.jobFilter', { status: jobStatus })} />
<MetricCard label={t('evolution.pendingReviewItems')} value={summary.pendingReviews} hint={t('evolution.reviewQueueHint')} />
<MetricCard label={t('evolution.pendingCandidates')} value={summary.pendingCandidates} hint={t('evolution.candidateFilter', { status: candidateStatus })} />
</section>
<section className="grid grid-cols-[1.1fr_0.9fr] gap-6">
<div className="space-y-6">
<div className="panel-surface p-5 space-y-4">
<div>
<div className="text-xs uppercase tracking-[0.16em] text-muted">{t('evolution.qualitySignalAudit')}</div>
<h2 className="text-2xl font-bold font-serif mt-1">{t('evolution.qualitySignals')}</h2>
</div>
{qualitySignalError ? <div className="text-sm text-danger">{qualitySignalError}</div> : null}
{!loading && !error && !qualitySignalError && qualitySignals.length === 0 ? (
<EmptyState title={t('evolution.noQualitySignals')} description={t('evolution.noQualitySignalsDesc')} />
) : null}
<div className="space-y-3">
{qualitySignals.map((signal) => (
<article key={signal.signal_ref || signal.job_id || signal.merge_key} className="record-card p-4 space-y-3">
<div className="flex items-start justify-between gap-4">
<button
type="button"
className="min-w-0 flex-1 bg-transparent p-0 text-left"
onClick={() => selectQualitySignal(signal)}
>
<div className="font-bold truncate">
{signal.signal_type || signal.actionability || t('evolution.qualitySignalLabel')}
</div>
<div className="text-xs text-muted font-mono break-all">
{signal.signal_ref || signal.job_id || signal.merge_key || t('common.none')}
</div>
</button>
<div className={`text-sm font-bold shrink-0 ${statusTone(signal.job_status || signal.admission_status)}`}>
{signal.job_status || signal.admission_status || t('evolution.signalOnly')}
</div>
</div>
<div className="grid grid-cols-3 gap-3 text-xs text-muted">
<div className="break-all">
{signal.subject_type || t('common.unknown')}: {signal.subject_id || signal.tool_key || signal.skill_id || t('common.none')}
</div>
<div className="break-all">
{signal.not_triggerable_reason || t('evolution.triggerable')}
</div>
<div className="break-all">
{t('evolution.admissionShort')} {signal.admission_status || t('common.none')}
</div>
</div>
<button
type="button"
className="btn-outline-ink text-xs"
onClick={() => selectQualitySignal(signal)}
>
{t('evolution.inspectSignal')}
</button>
</article>
))}
</div>
</div>
<div className="panel-surface p-5 space-y-4">
<div>
<div className="text-xs uppercase tracking-[0.16em] text-muted">{t('evolution.reviewEntrance')}</div>
<h2 className="text-2xl font-bold font-serif mt-1">{t('evolution.reviewQueue')}</h2>
</div>
{!loading && !error && reviewItems.length === 0 ? (
<EmptyState title={t('evolution.noReviewItems')} description={t('evolution.noReviewItemsDesc')} />
) : null}
<div className="space-y-3">
{reviewItems.map((item) => (
<article key={item.item_id} className="record-card p-4 space-y-3">
<div className="flex items-start justify-between gap-4">
<button
type="button"
className="min-w-0 flex-1 bg-transparent p-0 text-left"
onClick={() => void loadReviewItem(item)}
>
<div className="font-bold truncate">{item.title}</div>
<div className="text-xs text-muted font-mono break-all">{item.item_id}</div>
</button>
<div className={`text-sm font-bold shrink-0 ${statusTone(item.status)}`}>{item.status}</div>
</div>
<div className="grid grid-cols-3 gap-3 text-xs text-muted">
<div>{item.item_type}</div>
<div>{item.summary || item.review_note || t('common.none')}</div>
<div>{formatDate(item.updated_at || item.created_at)}</div>
</div>
<div className="flex gap-2">
<button
type="button"
className="btn-outline-ink text-xs"
onClick={() => void loadReviewItem(item)}
>
{t('evolution.inspectReview')}
</button>
{item.approval_available && item.action_kind === 'request_recheck' && item.candidate_id ? (
<button
type="button"
className="btn-outline-ink text-xs"
disabled={busyCandidateId === item.candidate_id}
onClick={() => {
void (async () => {
const candidate = candidates.find((candidateItem) => candidateItem.candidate_id === item.candidate_id)
?? await evolutionApi.getCandidate(item.candidate_id || '');
await requestCandidateRecheck(candidate);
})();
}}
>
{t('evolution.approveForRecheck')}
</button>
) : (
<span className="tag px-2 py-1 text-xs text-muted">
{t('evolution.inspectOnly')}
</span>
)}
</div>
</article>
))}
</div>
</div>
<div className="panel-surface p-5 space-y-4">
<div className="flex items-start justify-between gap-4">
<div>
<div className="text-xs uppercase tracking-[0.16em] text-muted">{t('evolution.auditJobs')}</div>
<h2 className="text-2xl font-bold font-serif mt-1">{t('evolution.triggerJobs')}</h2>
</div>
<select value={jobStatus} onChange={(event) => setJobStatus(event.target.value)} className="px-3 py-2 text-sm">
{JOB_STATUSES.map((status) => (
<option key={status} value={status}>{status}</option>
))}
</select>
</div>
{loading ? <div className="text-sm text-muted">{t('common.loading')}</div> : null}
{error ? <div className="text-sm text-danger">{error}</div> : null}
{!loading && !error && jobs.length === 0 ? (
<EmptyState title={t('evolution.noJobs')} description={t('evolution.noJobsDesc')} />
) : null}
<div className="space-y-3">
{jobs.map((job) => (
<article key={job.job_id} className="record-card p-4 space-y-3">
<div className="flex items-start justify-between gap-4">
<button
type="button"
className="min-w-0 flex-1 bg-transparent p-0 text-left"
onClick={() => void loadJob(job)}
>
<div className="font-bold truncate">{job.trigger_type} - {job.reason}</div>
<div className="text-xs text-muted font-mono break-all">{job.job_id}</div>
</button>
<div className={`text-sm font-bold shrink-0 ${statusTone(job.status)}`}>{job.status}</div>
</div>
<div className="grid grid-cols-3 gap-3 text-xs text-muted">
<div>{formatDate(job.created_at)}</div>
<div>{t('evolution.packets', { count: job.packet_ids.length })}</div>
<div>{t('evolution.decisions', { count: job.decision_ids.length })}</div>
</div>
{job.action_ids.length > 0 ? (
<div className="flex flex-wrap gap-2 text-xs">
{job.action_ids.map((actionId) => (
<button
key={actionId}
type="button"
className="tag px-2 py-1 hover:border-[color:var(--color-border-dark)]"
onClick={() => void loadAction(actionId)}
>
{truncate(actionId, 32)}
</button>
))}
</div>
) : null}
<button
type="button"
className="btn-outline-ink text-xs"
onClick={() => void loadJob(job)}
>
{t('evolution.inspectJob')}
</button>
</article>
))}
</div>
</div>
<div className="panel-surface p-5 space-y-4">
<div className="flex items-start justify-between gap-4">
<div>
<div className="text-xs uppercase tracking-[0.16em] text-muted">{t('evolution.candidates')}</div>
<h2 className="text-2xl font-bold font-serif mt-1">{t('evolution.admissionQueue')}</h2>
</div>
<select value={candidateStatus} onChange={(event) => setCandidateStatus(event.target.value)} className="px-3 py-2 text-sm">
{CANDIDATE_STATUSES.map((status) => (
<option key={status} value={status}>{status}</option>
))}
</select>
</div>
{!loading && !error && candidates.length === 0 ? (
<EmptyState title={t('evolution.noCandidates')} description={t('evolution.noCandidatesDesc')} />
) : null}
<div className="space-y-3">
{candidates.map((candidate) => (
<article key={candidate.candidate_id} className="record-card p-4 space-y-3">
<div className="flex items-start justify-between gap-4">
<button
type="button"
className="min-w-0 flex-1 bg-transparent p-0 text-left"
onClick={() => void loadCandidate(candidate)}
>
<div className="font-bold truncate">{candidate.proposed_action}</div>
<div className="text-xs text-muted font-mono break-all">{candidate.candidate_id}</div>
</button>
<div className={`text-sm font-bold shrink-0 ${statusTone(candidate.status)}`}>{candidate.status}</div>
</div>
<div className="grid grid-cols-3 gap-3 text-xs text-muted">
<div>{t('evolution.recurrence', { count: candidate.recurrence_count })}</div>
<div>{t('evolution.targets', { count: candidate.target_skill_ids.length })}</div>
<div>{formatDate(candidate.updated_at)}</div>
</div>
{candidate.status === 'pending' ? (
<div className="flex gap-2">
<button
type="button"
className="btn-outline-ink text-xs"
disabled={busyCandidateId === candidate.candidate_id}
onClick={() => void requestCandidateRecheck(candidate)}
>
{t('evolution.requestRecheck')}
</button>
<button
type="button"
className="btn-outline-ink text-xs"
disabled={busyCandidateId === candidate.candidate_id}
onClick={() => void rejectCandidate(candidate)}
>
{t('evolution.reject')}
</button>
</div>
) : null}
</article>
))}
</div>
</div>
</div>
<aside className="panel-surface p-5 space-y-5 self-start sticky top-6">
<div>
<div className="text-xs uppercase tracking-[0.16em] text-muted">{t('evolution.details')}</div>
<h2 className="text-2xl font-bold font-serif mt-1">{t('evolution.auditDetail')}</h2>
</div>
{detailLoading ? <div className="text-sm text-muted">{t('common.loading')}</div> : null}
{detailError ? <div className="text-sm text-danger">{detailError}</div> : null}
{candidateNotice ? <div className="text-sm text-primary">{candidateNotice}</div> : null}
{!selectedCandidate && !selectedAction && !selectedReviewItem && !selectedJob && !selectedQualitySignal ? (
<EmptyState title={t('evolution.noSelection')} description={t('evolution.noSelectionDesc')} />
) : null}
{selectedQualitySignal ? (
<section className="space-y-3 text-sm">
<div className="flex items-center justify-between gap-3">
<h3 className="font-bold">{t('evolution.selectedQualitySignal')}</h3>
<span className={statusTone(selectedQualitySignal.job_status || selectedQualitySignal.admission_status)}>
{selectedQualitySignal.job_status || selectedQualitySignal.admission_status || t('evolution.signalOnly')}
</span>
</div>
<div className="font-mono text-xs break-all">
{selectedQualitySignal.signal_ref || selectedQualitySignal.job_id || t('common.none')}
</div>
<div className="space-y-2">
<div><strong>{t('evolution.signalType')}</strong> {selectedQualitySignal.signal_type || t('common.none')}</div>
<div><strong>{t('evolution.subject')}</strong> {selectedQualitySignal.subject_type || t('common.unknown')} / <span className="font-mono break-all">{selectedQualitySignal.subject_id || t('common.none')}</span></div>
<div><strong>{t('evolution.toolKey')}</strong> <span className="font-mono break-all">{selectedQualitySignal.tool_key || t('common.none')}</span></div>
<div><strong>{t('evolution.skillId')}</strong> <span className="font-mono break-all">{selectedQualitySignal.skill_id || t('common.none')}</span></div>
<div><strong>{t('evolution.actionability')}</strong> {selectedQualitySignal.actionability || t('common.none')} / {selectedQualitySignal.evidence_status || t('common.none')}</div>
<div><strong>{t('evolution.notTriggerableReason')}</strong> {selectedQualitySignal.not_triggerable_reason || t('evolution.triggerable')}</div>
<div><strong>{t('evolution.job')}</strong> <span className="font-mono break-all">{selectedQualitySignal.job_id || t('common.none')}</span> / {selectedQualitySignal.job_status || t('common.none')}</div>
<div><strong>{t('evolution.admission')}</strong> {selectedQualitySignal.admission_status || t('common.none')}</div>
<div><strong>{t('evolution.hardFailures')}</strong> {selectedQualitySignal.admission_hard_failures.length ? selectedQualitySignal.admission_hard_failures.join(', ') : t('common.none')}</div>
<div><strong>{t('evolution.warnings')}</strong> {selectedQualitySignal.admission_warnings.length ? selectedQualitySignal.admission_warnings.join(', ') : t('common.none')}</div>
<div><strong>{t('evolution.rawBackrefs')}</strong> {selectedQualitySignal.raw_backref_count}</div>
<div><strong>{t('evolution.mergeKey')}</strong> <span className="font-mono break-all">{selectedQualitySignal.merge_key || t('common.none')}</span></div>
</div>
</section>
) : null}
{selectedJob ? (
<section className="space-y-3 text-sm">
<div className="flex items-center justify-between gap-3">
<h3 className="font-bold">{t('evolution.selectedJob')}</h3>
<span className={statusTone(selectedJob.status)}>{selectedJob.status}</span>
</div>
<div className="font-mono text-xs break-all">{selectedJob.job_id}</div>
<div className="space-y-2">
<div><strong>{t('evolution.trigger')}</strong> {selectedJob.trigger_type} / {selectedJob.reason}</div>
<div><strong>{t('evolution.profile')}</strong> {selectedJob.evidence_profile} / {selectedJob.subprofile}</div>
<div><strong>{t('evolution.watermark')}</strong> {selectedJob.manifest_watermark ?? t('common.none')}</div>
<div><strong>{t('evolution.error')}</strong> {selectedJob.error || t('common.none')}</div>
</div>
<div className="space-y-2">
<div className="font-bold">{t('evolution.linkedRecords')}</div>
<div className="flex flex-wrap gap-2">
{selectedJob.packet_ids.map((packetId) => (
<button
key={packetId}
type="button"
className="tag max-w-full px-2 py-1 text-xs"
onClick={() => {
void (async () => {
setDetailLoading(true);
try {
setSelectedDecision(await evolutionApi.getPacket(packetId));
} finally {
setDetailLoading(false);
}
})();
}}
>
{truncate(packetId, 32)}
</button>
))}
{selectedJob.decision_ids.map((decisionId) => (
<button
key={decisionId}
type="button"
className="tag max-w-full px-2 py-1 text-xs"
onClick={() => {
void (async () => {
setDetailLoading(true);
try {
setSelectedDecision(await evolutionApi.getDecision(decisionId));
} finally {
setDetailLoading(false);
}
})();
}}
>
{truncate(decisionId, 32)}
</button>
))}
{selectedJob.action_ids.map((actionId) => (
<button
key={actionId}
type="button"
className="tag max-w-full px-2 py-1 text-xs"
onClick={() => void loadAction(actionId)}
>
{truncate(actionId, 32)}
</button>
))}
</div>
</div>
{selectedDecision ? (
<div className="space-y-2">
<div className="font-bold">{t('evolution.selectedPayload')}</div>
<JsonPreview value={selectedDecision} />
</div>
) : null}
<JsonPreview value={selectedJob} />
</section>
) : null}
{selectedReviewItem && !selectedCandidate ? (
<section className="space-y-3 text-sm">
<div className="flex items-center justify-between gap-3">
<h3 className="font-bold">{t('evolution.selectedReviewItem')}</h3>
<span className={statusTone(selectedReviewItem.status)}>{selectedReviewItem.status}</span>
</div>
<div className="font-mono text-xs break-all">{selectedReviewItem.item_id}</div>
<div className="space-y-2">
<div><strong>{t('evolution.reviewType')}</strong> {selectedReviewItem.item_type}</div>
<div><strong>{t('evolution.reviewSummary')}</strong> {selectedReviewItem.summary || t('common.none')}</div>
<div><strong>{t('evolution.approvalState')}</strong> {selectedReviewItem.approval_available ? t('evolution.approvalAvailable') : t('evolution.inspectOnly')}</div>
<div><strong>{t('evolution.reviewNote')}</strong> {selectedReviewItem.review_note || t('common.none')}</div>
<div><strong>{t('evolution.decision')}</strong> <span className="font-mono break-all">{selectedReviewItem.decision_id || t('common.none')}</span></div>
</div>
{selectedDecision ? (
<div className="space-y-2">
<div className="font-bold">{t('evolution.decisionPayload')}</div>
<JsonPreview value={selectedDecision} />
</div>
) : null}
</section>
) : null}
{selectedCandidate ? (
<section className="space-y-3 text-sm">
<div className="flex items-center justify-between gap-3">
<h3 className="font-bold">{t('evolution.selectedCandidate')}</h3>
<span className={statusTone(selectedCandidate.status)}>{selectedCandidate.status}</span>
</div>
<div className="font-mono text-xs break-all">{selectedCandidate.candidate_id}</div>
<div className="space-y-2">
<div><strong>{t('evolution.decision')}</strong> <span className="font-mono break-all">{selectedCandidate.decision_id}</span></div>
<div><strong>{t('evolution.action')}</strong> {selectedCandidate.proposed_action}</div>
<div><strong>{t('evolution.targetsLabel')}</strong> {selectedCandidate.target_skill_ids.length ? selectedCandidate.target_skill_ids.join(', ') : t('common.none')}</div>
<div><strong>{t('evolution.blockedReason')}</strong> {selectedCandidate.blocked_reason || t('common.none')}</div>
<div><strong>{t('evolution.neededEvidence')}</strong> {selectedCandidate.needed_evidence?.length ? selectedCandidate.needed_evidence.join(', ') : t('common.none')}</div>
</div>
{selectedCandidate.evidence_refs.length > 0 ? (
<div className="space-y-2">
<div className="font-bold">{t('evolution.evidenceRefs')}</div>
<div className="flex flex-wrap gap-2">
{selectedCandidate.evidence_refs.map((refId) => (
<button
key={refId}
type="button"
className="tag max-w-full px-2 py-1 text-xs"
onClick={() => void loadRefPreview(refId)}
>
<span className="block max-w-[300px] truncate">{refId}</span>
</button>
))}
</div>
</div>
) : null}
{selectedDecision ? (
<div className="space-y-2">
<div className="font-bold">{t('evolution.decisionPayload')}</div>
<JsonPreview value={selectedDecision} />
</div>
) : null}
{selectedCandidate.last_recheck_result ? (
<div className="space-y-2">
<div className="font-bold">{t('evolution.lastRecheckResult')}</div>
<JsonPreview value={selectedCandidate.last_recheck_result} />
</div>
) : null}
</section>
) : null}
{selectedAction ? (
<section className="space-y-3 text-sm">
<div className="flex items-center justify-between gap-3">
<h3 className="font-bold">{t('evolution.selectedAction')}</h3>
<span className={statusTone(selectedAction.commit_status)}>{selectedAction.commit_status}</span>
</div>
<div className="font-mono text-xs break-all">{selectedAction.action_id}</div>
<div className="space-y-2">
<div><strong>{t('evolution.skillId')}</strong> <span className="font-mono break-all">{selectedAction.skill_id || t('common.none')}</span></div>
<div><strong>{t('evolution.targetDir')}</strong> <span className="break-all">{selectedAction.active_target_dir}</span></div>
<div><strong>{t('evolution.stagingDir')}</strong> <span className="break-all">{selectedAction.staging_dir}</span></div>
<div><strong>{t('evolution.changedFiles')}</strong> {selectedAction.changed_files.length ? selectedAction.changed_files.join(', ') : t('common.none')}</div>
{selectedAction.failure_reason ? (
<div className="text-danger"><strong>{t('evolution.failure')}</strong> {selectedAction.failure_reason}</div>
) : null}
</div>
<JsonPreview value={selectedAction} />
</section>
) : null}
{selectedRef ? (
<section className="space-y-2 text-sm">
<div className="font-bold">{t('evolution.refPreview')}</div>
<div className="font-mono text-xs break-all">{selectedRef.ref_id}</div>
<pre className="field-surface max-h-[260px] overflow-auto p-3 text-xs whitespace-pre-wrap">{selectedRef.content}</pre>
</section>
) : null}
</aside>
</section>
</div>
);
}

View file

@ -0,0 +1,319 @@
import { useEffect, useMemo, useState } from 'react';
import { Link, useSearchParams, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { skillsApi, type SkillDetail, type SkillLineage } from '../api';
import EmptyState from '../components/EmptyState';
import MetricCard from '../components/MetricCard';
import SkillEvolutionGraph from '../components/skill-detail/SkillEvolutionGraph';
import SkillVersionDrawer from '../components/skill-detail/SkillVersionDrawer';
import SkillVersionFilterBar from '../components/skill-detail/SkillVersionFilterBar';
import { useSkillEvolutionGraphData } from '../hooks/useSkillEvolutionGraphData';
import { formatDate } from '../utils/format';
const DRAWER_ANIMATION_DURATION_MS = 300;
export default function SkillDetailPage() {
const { t } = useTranslation();
const { skillId = '' } = useParams();
const [searchParams, setSearchParams] = useSearchParams();
const [skillClass, setSkillClass] = useState<SkillDetail | null>(null);
const [lineageGraph, setLineageGraph] = useState<SkillLineage | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedVersion, setSelectedVersion] = useState<SkillDetail | null>(null);
const [drawerVersion, setDrawerVersion] = useState<SkillDetail | null>(null);
const [drawerLoading, setDrawerLoading] = useState(false);
const [drawerError, setDrawerError] = useState<string | null>(null);
const [originFilter, setOriginFilter] = useState('all');
const [tagFilter, setTagFilter] = useState('all');
const selectedVersionId = searchParams.get('version');
useEffect(() => {
let cancelled = false;
const load = async () => {
setLoading(true);
setError(null);
setSkillClass(null);
setLineageGraph(null);
try {
const [detail, lineage] = await Promise.all([
skillsApi.getSkill(skillId),
skillsApi.getLineage(skillId),
]);
if (!cancelled) {
setSkillClass(detail);
setLineageGraph(lineage);
}
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : t('skillDetail.failedToLoad'));
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
};
if (skillId) {
void load();
}
return () => {
cancelled = true;
};
}, [skillId, t]);
useEffect(() => {
if (!selectedVersionId) {
setSelectedVersion(null);
setDrawerError(null);
return;
}
if (skillClass && selectedVersionId === skillClass.skill_id) {
setSelectedVersion(skillClass);
setDrawerError(null);
return;
}
let cancelled = false;
const loadSelectedVersion = async () => {
setDrawerLoading(true);
setDrawerError(null);
try {
const detail = await skillsApi.getSkill(selectedVersionId);
if (!cancelled) {
setSelectedVersion(detail);
}
} catch (err) {
if (!cancelled) {
setSelectedVersion(null);
setDrawerError(err instanceof Error ? err.message : t('skillDetail.failedToLoad'));
}
} finally {
if (!cancelled) {
setDrawerLoading(false);
}
}
};
void loadSelectedVersion();
return () => {
cancelled = true;
};
}, [selectedVersionId, skillClass, t]);
useEffect(() => {
if (selectedVersion) {
setDrawerVersion(selectedVersion);
return;
}
if (!drawerVersion) {
return;
}
const timeoutId = window.setTimeout(() => {
setDrawerVersion(null);
}, DRAWER_ANIMATION_DURATION_MS);
return () => {
window.clearTimeout(timeoutId);
};
}, [drawerVersion, selectedVersion]);
useEffect(() => {
if (!lineageGraph || !selectedVersionId) {
return;
}
const exists = lineageGraph.nodes.some((node) => node.skill_id === selectedVersionId);
if (!exists && skillClass && selectedVersionId !== skillClass.skill_id) {
const next = new URLSearchParams(searchParams);
next.delete('version');
setSearchParams(next);
}
}, [lineageGraph, searchParams, selectedVersionId, setSearchParams, skillClass]);
const alwaysVisibleSkillIds = useMemo(
() => [skillId, selectedVersionId].filter((value): value is string => Boolean(value)),
[selectedVersionId, skillId],
);
const { allOrigins, allTags, graphData } = useSkillEvolutionGraphData(
lineageGraph,
originFilter,
tagFilter,
alwaysVisibleSkillIds,
);
const classSummary = useMemo(() => {
const nodes = lineageGraph?.nodes ?? [];
if (nodes.length === 0) {
return {
versionCount: 0,
activeCount: 0,
bestScore: 0,
averageScore: 0,
maxGeneration: 0,
totalSelections: 0,
latestCreatedAt: null as string | null,
tags: [] as string[],
origins: [] as string[],
};
}
const tags = new Set<string>();
const origins = new Set<string>();
let totalSelections = 0;
let bestScore = 0;
let maxGeneration = 0;
let latestCreatedAt: string | null = null;
nodes.forEach((node) => {
node.tags.forEach((tag) => tags.add(tag));
origins.add(node.origin);
totalSelections += node.total_selections;
bestScore = Math.max(bestScore, node.score);
maxGeneration = Math.max(maxGeneration, node.generation);
if (!latestCreatedAt || Date.parse(node.created_at) > Date.parse(latestCreatedAt)) {
latestCreatedAt = node.created_at;
}
});
return {
versionCount: nodes.length,
activeCount: nodes.filter((node) => node.is_active).length,
bestScore,
averageScore: nodes.reduce((sum, node) => sum + node.score, 0) / nodes.length,
maxGeneration,
totalSelections,
latestCreatedAt,
tags: Array.from(tags).sort(),
origins: Array.from(origins).sort(),
};
}, [lineageGraph]);
const openVersion = (nextSkillId: string) => {
const next = new URLSearchParams(searchParams);
next.set('version', nextSkillId);
setSearchParams(next);
};
const closeDrawer = () => {
const next = new URLSearchParams(searchParams);
next.delete('version');
setSearchParams(next);
};
if (loading) {
return <div className="p-6 text-sm text-muted">{t('skillDetail.loadingDetail')}</div>;
}
if (error || !skillClass) {
return <div className="p-6 text-sm text-danger">{error ?? t('skillDetail.skillNotFound')}</div>;
}
return (
<div className="p-6 space-y-6 relative">
<div className="flex items-center gap-4">
<Link to="/skills" className="chip text-sm transition-colors hover:border-[color:var(--color-border-dark)] hover:text-ink">{t('skillDetail.backToSkills')}</Link>
<div className="min-w-0">
<h1 className="text-3xl font-bold font-serif truncate">{skillClass.name}</h1>
<div className="text-sm text-muted mt-1">{t('skillDetail.anchoredOn', { id: skillClass.skill_id })}</div>
</div>
</div>
<section className="panel-surface p-5 space-y-4">
<div className="flex items-start justify-between gap-6">
<div className="space-y-3 min-w-0 flex-1">
<div>
<div className="text-xs uppercase tracking-[0.16em] text-muted">{t('skillDetail.skillClass')}</div>
<h2 className="text-2xl font-bold font-serif mt-1">{t('skillDetail.evolutionOverview')}</h2>
</div>
<div className="flex flex-wrap gap-2 text-xs">
<span className="tag px-2 py-1">{skillClass.category}</span>
<span className="tag px-2 py-1">{skillClass.visibility}</span>
<span className="tag px-2 py-1">{skillClass.is_active ? t('skillDetail.activeTip') : t('skillDetail.inactiveAnchor')}</span>
{classSummary.origins.map((origin) => (
<span key={origin} className="tag px-2 py-1">{origin}</span>
))}
{classSummary.tags.slice(0, 8).map((tag) => (
<span key={tag} className="tag px-2 py-1">{tag}</span>
))}
{classSummary.tags.length > 8 ? (
<span className="tag px-2 py-1">{t('common.tags', { count: classSummary.tags.length - 8 })}</span>
) : null}
</div>
</div>
<div className="shrink-0 text-right">
<div className="text-5xl font-bold font-serif leading-none">{classSummary.bestScore.toFixed(1)}</div>
<div className="text-xs uppercase tracking-[0.16em] text-muted mt-2">{t('skillDetail.bestVersionScore')}</div>
</div>
</div>
<div className="grid grid-cols-2 gap-4 text-sm text-muted">
<div>
<div className="font-bold text-ink">{t('skillDetail.skillDirectory')}</div>
<div className="break-all">{skillClass.skill_dir || t('common.unavailable')}</div>
</div>
<div>
<div className="font-bold text-ink">{t('skillDetail.latestVersionCreated')}</div>
<div>{formatDate(classSummary.latestCreatedAt)}</div>
</div>
<div>
<div className="font-bold text-ink">{t('skillDetail.representativeVersion')}</div>
<div className="break-all">{skillClass.skill_id}</div>
</div>
<div>
<div className="font-bold text-ink">{t('skillDetail.representativeUpdate')}</div>
<div>{formatDate(skillClass.last_updated)}</div>
</div>
</div>
</section>
<section className="metrics-row">
<MetricCard label={t('skillDetail.versions')} value={classSummary.versionCount} hint={t('skillDetail.maxGeneration', { count: classSummary.maxGeneration })} />
<MetricCard label={t('skillDetail.activeVersions')} value={classSummary.activeCount} hint={t('skillDetail.originsCount', { count: classSummary.origins.length })} />
<MetricCard label={t('skillDetail.averageScore')} value={classSummary.averageScore.toFixed(1)} hint={t('skillDetail.acrossAllVersions')} />
<MetricCard label={t('skillDetail.selections')} value={classSummary.totalSelections} hint={t('skillDetail.representativeScore', { score: skillClass.score.toFixed(1) })} />
</section>
<section className="panel-surface overflow-hidden relative min-h-[620px]">
<div className="px-5 py-4 border-b border-[color:var(--color-border)] bg-surface flex items-center justify-between gap-4 flex-wrap">
<div>
<div className="text-xs uppercase tracking-[0.16em] text-muted">{t('skillDetail.evolutionGraph')}</div>
<h2 className="text-2xl font-bold font-serif mt-1">{t('skillDetail.versionLineage')}</h2>
</div>
<SkillVersionFilterBar
originFilter={originFilter}
onOriginFilterChange={setOriginFilter}
tagFilter={tagFilter}
onTagFilterChange={setTagFilter}
allOrigins={allOrigins}
allTags={allTags}
/>
</div>
<SkillEvolutionGraph
graphData={graphData}
selectedNodeId={selectedVersionId}
onNodeClick={(node) => openVersion(node.id)}
onBackgroundClick={closeDrawer}
/>
{drawerLoading ? (
<div className="absolute bottom-4 left-4 text-xs text-muted">{t('skillDetail.loadingDrawer')}</div>
) : null}
{drawerError ? (
<div className="absolute bottom-4 left-4 text-xs text-danger">{drawerError}</div>
) : null}
</section>
{lineageGraph && lineageGraph.nodes.length === 0 ? (
<EmptyState title={t('skillDetail.noLineageGraph')} description={t('skillDetail.noLineageGraphDesc')} />
) : null}
<SkillVersionDrawer skill={drawerVersion} isOpen={Boolean(selectedVersion)} onClose={closeDrawer} />
</div>
);
}

View file

@ -0,0 +1,174 @@
import { useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { skillsApi, type Skill, type SkillStats } from '../api';
import EmptyState from '../components/EmptyState';
import MetricCard from '../components/MetricCard';
import { formatDate, truncate } from '../utils/format';
import { buildSkillClasses } from '../utils/skillClasses';
export default function SkillsPage() {
const { t } = useTranslation();
const [skills, setSkills] = useState<Skill[]>([]);
const [stats, setStats] = useState<SkillStats | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [query, setQuery] = useState('');
const [sort, setSort] = useState<'score' | 'updated' | 'name'>('score');
useEffect(() => {
let cancelled = false;
const load = async () => {
setLoading(true);
setError(null);
try {
const [skillItems, skillStats] = await Promise.all([
skillsApi.listSkills({ activeOnly: false, sort, limit: 200 }),
skillsApi.getSkillStats(),
]);
if (!cancelled) {
setSkills(skillItems);
setStats(skillStats);
}
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : t('skills.failedToLoad'));
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
};
void load();
return () => {
cancelled = true;
};
}, [sort, t]);
const skillClasses = useMemo(() => buildSkillClasses(skills), [skills]);
const filteredClasses = useMemo(() => {
const normalized = query.trim().toLowerCase();
const base = !normalized
? skillClasses
: skillClasses.filter((skillClass) => {
const searchCorpus = [
skillClass.representative.name,
skillClass.representative.skill_id,
skillClass.representative.description,
...skillClass.tags,
...skillClass.origins,
...skillClass.versions.flatMap((version) => [version.skill_id, version.name, version.description, ...version.tags]),
].join('\n').toLowerCase();
return searchCorpus.includes(normalized);
});
return [...base].sort((left, right) => {
if (sort === 'name') {
return left.representative.name.localeCompare(right.representative.name);
}
if (sort === 'updated') {
return Date.parse(right.latest_updated) - Date.parse(left.latest_updated);
}
return right.best_score - left.best_score;
});
}, [query, skillClasses, sort]);
const totalActiveVersions = useMemo(
() => skillClasses.reduce((sum, skillClass) => sum + skillClass.active_count, 0),
[skillClasses],
);
const averageBestScore = useMemo(() => {
if (skillClasses.length === 0) {
return 0;
}
return skillClasses.reduce((sum, skillClass) => sum + skillClass.best_score, 0) / skillClasses.length;
}, [skillClasses]);
return (
<div className="p-6 space-y-6">
<div className="flex items-end justify-between gap-4">
<div>
<h1 className="text-3xl font-bold font-serif">{t('skills.title')}</h1>
</div>
<div className="flex gap-3 items-center">
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={t('skills.searchPlaceholder')}
className="px-3 py-2 min-w-[320px]"
/>
<select value={sort} onChange={(event) => setSort(event.target.value as typeof sort)} className="px-3 py-2">
<option value="score">{t('skills.sortByScore')}</option>
<option value="updated">{t('skills.sortByUpdated')}</option>
<option value="name">{t('skills.sortByName')}</option>
</select>
</div>
</div>
{stats ? (
<section className="metrics-row">
<MetricCard label={t('skills.skillClasses')} value={skillClasses.length} hint={t('skills.versionsHint', { count: stats.total_skills_all })} />
<MetricCard label={t('skills.activeVersions')} value={totalActiveVersions} hint={t('skills.withActivity', { count: stats.skills_with_activity })} />
<MetricCard label={t('skills.avgBestScore')} value={averageBestScore.toFixed(1)} hint={t('skills.bestNodeScoreHint')} />
<MetricCard label={t('skills.selections')} value={stats.total_selections} hint={t('skills.completionsHint', { count: stats.total_completions })} />
</section>
) : null}
{loading ? <div className="text-sm text-muted">{t('skills.loadingSkills')}</div> : null}
{error ? <div className="text-sm text-danger">{error}</div> : null}
{!loading && !error && filteredClasses.length === 0 ? (
<EmptyState title={t('skills.noSkillsMatch')} description={t('skills.noSkillsMatchDesc')} />
) : null}
{!loading && !error && filteredClasses.length > 0 ? (
<div className="grid grid-cols-2 gap-4">
{filteredClasses.map((skillClass) => (
<Link
key={skillClass.class_id}
to={`/skills/${encodeURIComponent(skillClass.representative.skill_id)}`}
className="record-card bg-surface p-4 space-y-4 hover:border-primary transition-colors"
>
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 space-y-1">
<div className="font-bold truncate">{skillClass.representative.name}</div>
<div className="text-xs text-muted truncate">{skillClass.representative.skill_id}</div>
</div>
<div className="text-right shrink-0">
<div className="text-3xl font-bold font-serif leading-none">{skillClass.best_score.toFixed(1)}</div>
<div className="text-xs text-muted">{t('skills.bestScore')}</div>
</div>
</div>
<div className="text-sm text-muted">
{truncate(skillClass.representative.description || t('skills.noClassDescription'), 160)}
</div>
<div className="grid grid-cols-4 gap-3 text-xs text-muted">
<div>{t('skills.versions', { count: skillClass.version_count })}</div>
<div>{t('skills.active', { count: skillClass.active_count })}</div>
<div>{t('skills.selectionsCount', { count: skillClass.total_selections })}</div>
<div>{formatDate(skillClass.latest_updated)}</div>
</div>
<div className="flex flex-wrap gap-2 text-xs">
{skillClass.origins.map((origin) => (
<span key={`${skillClass.class_id}-${origin}`} className="tag px-2 py-1">{origin}</span>
))}
{skillClass.tags.slice(0, 5).map((tag) => (
<span key={`${skillClass.class_id}-${tag}`} className="tag px-2 py-1">{tag}</span>
))}
{skillClass.tags.length > 5 ? (
<span className="tag px-2 py-1">{t('common.tags', { count: skillClass.tags.length - 5 })}</span>
) : null}
</div>
</Link>
))}
</div>
) : null}
</div>
);
}

View file

@ -0,0 +1,780 @@
import { useEffect, useMemo, useState, type ReactNode } from 'react';
import { Link, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { workflowsApi, type WorkflowDetail, type WorkflowTimelineEvent } from '../api';
import { formatDate, formatInstruction } from '../utils/format';
function stringify(value: unknown): string {
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function getStringArray(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}
return value.filter((item): item is string => typeof item === 'string' && item.trim().length > 0);
}
function formatDurationSeconds(value?: number | null): string {
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
return '—';
}
if (value === 0) {
return '0s';
}
if (value < 60) {
return `${value.toFixed(value >= 10 ? 0 : 1)}s`;
}
const totalSeconds = Math.round(value);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
if (hours > 0) {
return `${hours}h ${minutes}m`;
}
return `${minutes}m ${seconds}s`;
}
function formatPercent(value: number): string {
return `${(value * 100).toFixed(1)}%`;
}
function getString(value: unknown): string | null {
return typeof value === 'string' && value.trim().length > 0 ? value : null;
}
function collapseWhitespace(value: string): string {
return value.replace(/\s+/g, ' ').trim();
}
function truncateText(value: string, max = 180): string {
const compact = collapseWhitespace(value);
if (compact.length <= max) {
return compact;
}
return `${compact.slice(0, max).trimEnd()}`;
}
function firstMeaningfulLine(value: string): string | null {
const lines = value
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.length > 0 && !line.startsWith('```'));
return lines[0] ?? null;
}
function humanizeToken(value: string): string {
const normalized = value.replace(/[_-]+/g, ' ').replace(/\s+/g, ' ').trim();
if (!normalized) {
return 'Unknown';
}
return normalized.replace(/\b\w/g, (char) => char.toUpperCase());
}
function parseTimestamp(value?: string | null): Date | null {
if (!value) {
return null;
}
const normalized = value.replace(/(\.\d{3})\d+/, '$1');
const date = new Date(normalized);
if (Number.isNaN(date.getTime())) {
return null;
}
return date;
}
function formatTimeLabel(value?: string | null): string {
if (!value) {
return '—';
}
const match = value.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?/);
if (match) {
const [, , month, day, hour, minute, second = '00'] = match;
return `${month}/${day} ${hour}:${minute}:${second}`;
}
const date = parseTimestamp(value);
if (!date) {
return value;
}
return new Intl.DateTimeFormat('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
}).format(date);
}
function getTimestampSortValue(value?: string | null): number | null {
const date = parseTimestamp(value);
return date ? date.getTime() : null;
}
function getCommandTitle(command?: string | null): string | null {
if (!command) {
return null;
}
const compact = collapseWhitespace(command);
if (!compact || compact.startsWith('```') || compact.length > 48 || /[\\/]/.test(compact)) {
return null;
}
return /^[a-z0-9_-]+$/i.test(compact) ? humanizeToken(compact) : compact;
}
function getCommandPreview(command?: string | null, max = 140): string | null {
if (!command) {
return null;
}
return truncateText(firstMeaningfulLine(command) ?? command, max);
}
type TimelineTone = 'default' | 'primary' | 'accent' | 'danger';
interface TimelineFact {
label: string;
value: string;
}
interface TimelinePresentation {
title: string;
summary?: string;
secondary?: string;
primaryFact?: TimelineFact;
secondaryFact?: TimelineFact;
facts: TimelineFact[];
}
interface TimelineSummary {
total: number;
byType: Record<string, number>;
byAgentType: Record<string, number>;
byBackend: Record<string, number>;
firstTimestamp: string | null;
lastTimestamp: string | null;
}
interface SummaryMetricProps {
label: string;
value: ReactNode;
hint: string;
}
function SummaryMetric({ label, value, hint }: SummaryMetricProps) {
return (
<div className="workflow-metric-card">
<div className="workflow-metric-header">
<div className="workflow-kicker">{label}</div>
</div>
<div className="workflow-metric-value">{value}</div>
<div className="workflow-metric-divider" />
<div className="workflow-metric-hint">{hint}</div>
</div>
);
}
interface SidebarRowProps {
label: string;
children: ReactNode;
}
function SidebarRow({ label, children }: SidebarRowProps) {
return (
<div className="space-y-1.5">
<div className="workflow-kicker">{label}</div>
<div className="min-w-0 text-sm leading-6 text-ink">{children}</div>
</div>
);
}
interface WorkflowChipProps {
children: ReactNode;
className?: string;
}
function WorkflowChip({ children, className = '' }: WorkflowChipProps) {
return <span className={`workflow-chip text-xs ${className}`.trim()}>{children}</span>;
}
interface QuietEmptyStateProps {
title: string;
description: string;
}
function QuietEmptyState({ title, description }: QuietEmptyStateProps) {
return (
<div className="workflow-soft-card p-6 space-y-2">
<div className="text-lg font-semibold tracking-[-0.02em] text-ink">{title}</div>
<p className="workflow-copy text-sm leading-6 text-muted">{description}</p>
</div>
);
}
function incrementCount(counter: Record<string, number>, key?: string | null): void {
if (!key) {
return;
}
const normalized = key.trim();
if (!normalized) {
return;
}
counter[normalized] = (counter[normalized] ?? 0) + 1;
}
function getStatusTone(status?: string): TimelineTone {
if (status === 'success') {
return 'accent';
}
if (status === 'error') {
return 'danger';
}
return 'default';
}
function getEventTone(event: WorkflowTimelineEvent): TimelineTone {
if (event.type === 'agent_action') {
return 'primary';
}
return getStatusTone(event.status);
}
function getMarkerClasses(tone: TimelineTone): string {
switch (tone) {
case 'primary':
return 'border-[color:var(--color-primary)] bg-[color:var(--color-bg-page)] text-primary';
case 'accent':
return 'border-[color:var(--color-diff-add)] bg-[color:var(--color-diff-add)] text-ink';
case 'danger':
return 'border-[color:var(--color-diff-del)] bg-[color:var(--color-diff-del)] text-ink';
default:
return 'border-[color:var(--color-border-dark)] bg-surface text-muted';
}
}
function getStatusChipClasses(status?: string): string {
switch (status) {
case 'success':
return '[--workflow-chip-border:var(--color-diff-add)] [--workflow-chip-bg:var(--color-diff-add)] [--workflow-chip-text:var(--color-ink)]';
case 'error':
return '[--workflow-chip-border:var(--color-diff-del)] [--workflow-chip-bg:var(--color-diff-del)] [--workflow-chip-text:var(--color-ink)]';
default:
return '';
}
}
function isLowSignalPreview(value: string): boolean {
const compact = collapseWhitespace(value);
return compact.length <= 3 && /^(\/\*\*?|\/\/|#|[{[(])$/.test(compact);
}
function describeTimelineEvent(event: WorkflowTimelineEvent): TimelinePresentation {
const details = isRecord(event.details) ? event.details : null;
if (event.type === 'agent_action') {
const input = details && isRecord(details.input) ? details.input : null;
const reasoning = details && isRecord(details.reasoning) ? details.reasoning : null;
const instruction = getString(input?.instruction);
const response = getString(reasoning?.response);
const thought = getString(reasoning?.thought);
const responsePreview = response ? firstMeaningfulLine(response) ?? response : null;
const thoughtPreview = thought ? firstMeaningfulLine(thought) ?? thought : null;
const facts: TimelineFact[] = [];
const primaryFact = event.agent_name ? { label: 'Agent', value: event.agent_name } : undefined;
const secondaryFact = event.agent_type ? { label: 'Type', value: humanizeToken(event.agent_type) } : undefined;
return {
title: humanizeToken(event.label || 'agent_action'),
summary: instruction ? truncateText(instruction, 220) : undefined,
secondary: responsePreview
? `Response · ${truncateText(responsePreview, 180)}`
: thoughtPreview
? `Thought · ${truncateText(thoughtPreview, 180)}`
: undefined,
primaryFact,
secondaryFact,
facts,
};
}
const result = details && isRecord(details.result) ? details.result : null;
const command = getString(details?.command);
const commandTitle = getCommandTitle(command);
const commandPreview = getCommandPreview(command);
const toolName = event.label ? humanizeToken(event.label) : null;
const stdout = getString(result?.stdout);
const stderr = getString(result?.stderr);
const output = getString(result?.output);
const content = getString(result?.content);
const outputPreviewSource = stderr ?? output ?? stdout ?? content;
const outputPreview = outputPreviewSource
? firstMeaningfulLine(outputPreviewSource) ?? outputPreviewSource
: null;
const resolvedTitle = commandTitle ?? toolName ?? humanizeToken(event.backend ? `${event.backend}_execution` : 'tool_execution');
const primaryFact = toolName && toolName !== commandTitle
? { label: 'Tool', value: toolName }
: event.backend
? { label: 'Backend', value: humanizeToken(event.backend) }
: undefined;
const secondaryFact = commandPreview && commandPreview !== resolvedTitle
? { label: 'Command', value: truncateText(commandPreview, 72) }
: undefined;
const shouldHideSummary = outputPreview ? isLowSignalPreview(outputPreview) : false;
const facts: TimelineFact[] = [];
if (event.backend) {
facts.push({ label: 'Backend', value: humanizeToken(event.backend) });
}
if (toolName && toolName !== commandTitle && primaryFact?.value !== toolName) {
facts.push({ label: 'Tool', value: toolName });
}
if (typeof result?.exit_code === 'number') {
facts.push({ label: 'Exit', value: String(result.exit_code) });
}
return {
title: resolvedTitle,
summary: outputPreview && !shouldHideSummary
? `${stderr ? 'stderr · ' : ''}${truncateText(outputPreview, 220)}`
: undefined,
secondary: undefined,
primaryFact,
secondaryFact,
facts,
};
}
export default function WorkflowDetailPage() {
const { t } = useTranslation();
const { workflowId = '' } = useParams();
const [workflow, setWorkflow] = useState<WorkflowDetail | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [openIndex, setOpenIndex] = useState<number | null>(null);
useEffect(() => {
let cancelled = false;
const load = async () => {
setLoading(true);
setError(null);
try {
const detail = await workflowsApi.getWorkflow(workflowId);
if (!cancelled) {
setWorkflow(detail);
}
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : t('workflowDetail.failedToLoad'));
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
};
if (workflowId) {
void load();
}
return () => {
cancelled = true;
};
}, [workflowId, t]);
const timeline = useMemo(() => {
const events = workflow?.timeline ?? [];
return events
.map((event, originalIndex) => ({
event,
originalIndex,
timestampValue: getTimestampSortValue(event.timestamp),
}))
.sort((left, right) => {
if (left.timestampValue !== null && right.timestampValue !== null && left.timestampValue !== right.timestampValue) {
return left.timestampValue - right.timestampValue;
}
if (left.timestampValue !== null && right.timestampValue === null) {
return -1;
}
if (left.timestampValue === null && right.timestampValue !== null) {
return 1;
}
const rawTimestampDiff = (left.event.timestamp ?? '').localeCompare(right.event.timestamp ?? '');
if (rawTimestampDiff !== 0) {
return rawTimestampDiff;
}
return left.originalIndex - right.originalIndex;
})
.map(({ event }) => event);
}, [workflow]);
const timelineSummary = useMemo<TimelineSummary>(() => {
const byType: Record<string, number> = {};
const byAgentType: Record<string, number> = {};
const byBackend: Record<string, number> = {};
let firstTimestamp: string | null = null;
let lastTimestamp: string | null = null;
for (const event of timeline) {
incrementCount(byType, event.type);
incrementCount(byAgentType, event.agent_type);
incrementCount(byBackend, event.backend);
if (getTimestampSortValue(event.timestamp) !== null) {
if (!firstTimestamp) {
firstTimestamp = event.timestamp;
}
lastTimestamp = event.timestamp;
}
}
return {
total: timeline.length,
byType,
byAgentType,
byBackend,
firstTimestamp,
lastTimestamp,
};
}, [timeline]);
if (loading) {
return (
<div className="workflow-detail-page p-6">
<div className="mx-auto max-w-[1480px]">
<div className="workflow-panel p-6 text-sm text-muted">{t('workflowDetail.loadingDetail')}</div>
</div>
</div>
);
}
if (error || !workflow) {
return (
<div className="workflow-detail-page p-6">
<div className="mx-auto max-w-[1480px]">
<div className="workflow-panel p-6 text-sm text-danger">{error ?? t('workflowDetail.workflowNotFound')}</div>
</div>
</div>
);
}
const metadata = workflow.metadata ?? {};
const enabledBackends = getStringArray(metadata.backends).sort((left, right) => left.localeCompare(right));
const activityEntries = Object.entries(workflow.backend_counts).sort(([, left], [, right]) => right - left);
const topBackendEntry = activityEntries[0];
const skillSelection = isRecord(metadata.skill_selection) ? metadata.skill_selection : null;
const selectionMethod = skillSelection && typeof skillSelection.method === 'string' ? skillSelection.method : null;
const executionDurationLabel = formatDurationSeconds(workflow.execution_time);
const selectedSkillLabel = t('workflowDetail.skillsSelected', { count: workflow.selected_skills.length });
const iterationsLabel = t('workflowDetail.iteration', { count: workflow.iterations });
const totalStepLabel = t('workflowDetail.step', { count: workflow.total_steps });
const actionCountLabel = t('workflowDetail.agentAction', { count: workflow.agent_action_count });
const agentActionCount = timelineSummary.byType.agent_action ?? 0;
const toolExecutionCount = timelineSummary.byType.tool_execution ?? 0;
const latestEventLabel = timelineSummary.lastTimestamp ? formatTimeLabel(timelineSummary.lastTimestamp) : '—';
const timelineEventLabel = t('workflowDetail.mergedEvent', { count: timelineSummary.total });
const statusLabel = humanizeToken(workflow.status || 'unknown');
const selectionMethodLabel = selectionMethod ? humanizeToken(selectionMethod) : t('workflowDetail.notRecorded');
const successRateLabel = formatPercent(workflow.success_rate);
return (
<div className="workflow-detail-page p-6">
<div className="mx-auto max-w-[1480px] space-y-6">
<section className="workflow-hero p-6 lg:p-8 space-y-8">
<div className="flex flex-col gap-6 xl:flex-row xl:items-start xl:justify-between">
<div className="min-w-0 flex-1 space-y-5">
<div className="flex flex-wrap items-center gap-3">
<Link
to="/workflows"
className="workflow-chip text-sm transition-colors hover:border-[color:var(--color-border-dark)] hover:text-ink"
>
{t('workflowDetail.backToWorkflows')}
</Link>
<WorkflowChip className={getStatusChipClasses(workflow.status)}>{statusLabel}</WorkflowChip>
<WorkflowChip>{selectedSkillLabel}</WorkflowChip>
<WorkflowChip>{timelineEventLabel}</WorkflowChip>
</div>
<div className="space-y-3">
<div className="workflow-kicker">{t('workflowDetail.workflowDetail')}</div>
<h1 className="max-w-5xl text-4xl font-semibold leading-[1.05] tracking-[-0.05em] text-ink lg:text-5xl xl:text-[3.6rem]">
{workflow.task_name}
</h1>
<p className="workflow-copy max-w-4xl text-lg leading-8 text-muted line-clamp-4">
{formatInstruction(workflow.instruction, 480, t('format.noInstruction'))}
</p>
</div>
</div>
<div className="workflow-soft-card w-full max-w-sm shrink-0 p-5 space-y-5">
<p className="workflow-copy text-base leading-7 text-muted">
{t('workflowDetail.runDescription', { status: statusLabel, iterations: iterationsLabel, actions: actionCountLabel })}
</p>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1">
<div className="workflow-kicker">{t('workflowDetail.started')}</div>
<div className="text-base font-medium text-ink">{formatDate(workflow.start_time)}</div>
</div>
<div className="space-y-1">
<div className="workflow-kicker">{t('workflowDetail.duration')}</div>
<div className="text-base font-medium text-ink">{executionDurationLabel}</div>
</div>
<div className="space-y-1">
<div className="workflow-kicker">{t('workflowDetail.stepsLabel')}</div>
<div className="text-base font-medium text-ink">{totalStepLabel}</div>
</div>
<div className="space-y-1">
<div className="workflow-kicker">{t('workflowDetail.latestEvent')}</div>
<div className="text-base font-medium text-ink">{latestEventLabel}</div>
</div>
</div>
</div>
</div>
<section className="workflow-metrics-row">
<SummaryMetric
label={t('workflowDetail.successRate')}
value={successRateLabel}
hint={t('workflowDetail.successRateHint', { count: workflow.success_count, iterations: iterationsLabel })}
/>
<SummaryMetric
label={t('workflowDetail.iterations')}
value={workflow.iterations}
hint={t('workflowDetail.totalRuntime', { duration: executionDurationLabel })}
/>
<SummaryMetric
label={t('workflowDetail.activeBackends')}
value={activityEntries.length}
hint={topBackendEntry ? t('workflowDetail.mostActive', { backend: humanizeToken(topBackendEntry[0]), count: topBackendEntry[1] }) : t('workflowDetail.noRecordedActivity')}
/>
<SummaryMetric
label={t('workflowDetail.timelineEvents')}
value={timelineSummary.total}
hint={t('workflowDetail.agentToolHint', { agentCount: agentActionCount, toolCount: toolExecutionCount })}
/>
</section>
</section>
<section className="grid items-start gap-6 xl:grid-cols-[minmax(0,1.45fr)_minmax(320px,0.72fr)]">
<div className="workflow-panel p-5 space-y-4">
{timeline.length === 0 ? (
<QuietEmptyState
title={t('workflowDetail.noTimelineData')}
description={t('workflowDetail.noTimelineDesc')}
/>
) : (
<div role="list" aria-label="Workflow timeline events">
{timeline.map((event, index) => {
const isOpen = openIndex === index;
const presentation = describeTimelineEvent(event);
const markerClasses = getMarkerClasses(getEventTone(event));
const visibleIdentity = presentation.primaryFact?.value ?? null;
const visibleMeta = presentation.secondaryFact ?? null;
return (
<article key={`${event.timestamp}-${event.type}-${index}`} className="workflow-accordion-item flex gap-3" role="listitem">
<div className="flex w-10 shrink-0 flex-col items-center self-stretch">
<div className={`flex h-8 w-8 items-center justify-center rounded-full border text-[11px] font-semibold ${markerClasses}`}>
{index + 1}
</div>
{index < timeline.length - 1 ? <div className="mt-2.5 w-px flex-1 bg-[color:var(--color-border)]" /> : null}
</div>
<div className={`workflow-expand flex-1${isOpen ? ' is-opened' : ''}`}>
<button
type="button"
className="w-full cursor-pointer border-0 bg-transparent p-0 text-left font-inherit"
onClick={() => setOpenIndex(isOpen ? null : index)}
aria-expanded={isOpen}
>
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0 space-y-2">
<div className="flex flex-wrap items-center gap-2">
<div className="workflow-kicker">{humanizeToken(event.type)}</div>
{event.status ? (
<WorkflowChip className={getStatusChipClasses(event.status)}>
{humanizeToken(event.status)}
</WorkflowChip>
) : null}
</div>
<div className="space-y-1.5">
<h3 className="text-lg font-semibold leading-tight tracking-[-0.03em] text-ink">
{presentation.title}
</h3>
{(visibleIdentity || visibleMeta) ? (
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm leading-6 text-muted">
{visibleIdentity ? (
<span className="font-medium text-ink">{visibleIdentity}</span>
) : null}
{visibleMeta ? (
<span>
<span className="workflow-inline-label">{visibleMeta.label}</span>
{visibleMeta.value}
</span>
) : null}
</div>
) : null}
</div>
</div>
<div className="flex items-start gap-3">
<div className="shrink-0 text-left lg:text-right">
<div className="text-sm text-muted">{formatTimeLabel(event.timestamp)}</div>
</div>
<span className="workflow-expand-summary">
<span className="workflow-expand-icon" aria-hidden="true">
<span className="workflow-toggle-line" />
<span className="workflow-toggle-line is-2" />
</span>
</span>
</div>
</div>
</button>
<div className="workflow-accordion-content">
<div className="overflow-hidden">
<div className="workflow-expand-body mt-3 space-y-3 border-t border-[color:var(--color-border)] pt-3">
{presentation.summary ? (
<p className="workflow-copy text-[15px] leading-6 text-ink">{presentation.summary}</p>
) : null}
{presentation.secondary ? (
<p className="text-sm leading-6 text-muted">{presentation.secondary}</p>
) : null}
{presentation.facts.length > 0 ? (
<dl className="flex flex-wrap gap-x-5 gap-y-2">
{presentation.facts.map((fact) => (
<div key={`${fact.label}-${fact.value}`} className="flex min-w-0 items-baseline gap-2">
<dt className="text-[11px] uppercase tracking-[0.16em] text-muted whitespace-nowrap">{fact.label}</dt>
<dd className="break-words text-sm leading-6 text-ink">{fact.value}</dd>
</div>
))}
</dl>
) : null}
<div className="workflow-soft-card p-3.5">
<div className="text-[11px] uppercase tracking-[0.16em] text-muted">{t('workflowDetail.rawEventJson')}</div>
<pre className="workflow-json mt-3 whitespace-pre-wrap break-all text-xs leading-6 text-muted">
{stringify(event.details)}
</pre>
</div>
</div>
</div>
</div>
</div>
</article>
);
})}
</div>
)}
</div>
<aside className="space-y-4 xl:sticky xl:top-6">
<section className="workflow-panel p-5 space-y-4">
<div className="flex items-start justify-between gap-3">
<div>
<div className="workflow-kicker">{t('workflowDetail.selection')}</div>
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.03em] text-ink">{t('workflowDetail.selectedSkills')}</h2>
</div>
{workflow.selected_skills.length > 0 ? <WorkflowChip>{workflow.selected_skills.length}</WorkflowChip> : null}
</div>
{workflow.selected_skills.length > 0 ? (
<div className="flex flex-wrap gap-2 text-xs">
{workflow.selected_skills.map((skillId, index) => (
<Link
key={`${skillId}-${index}`}
to={`/skills/${encodeURIComponent(skillId)}`}
title={skillId}
className="workflow-chip inline-flex max-w-full items-center transition-colors hover:border-[color:var(--color-border-dark)] hover:text-ink"
>
<span className="block max-w-[240px] truncate">{skillId}</span>
</Link>
))}
</div>
) : (
<div className="space-y-2">
<div className="text-lg font-semibold tracking-[-0.02em] text-ink">{t('workflowDetail.noSelectedSkills')}</div>
<p className="workflow-copy text-sm leading-6 text-muted">{t('workflowDetail.noSelectedSkillsDesc')}</p>
</div>
)}
</section>
<section className="workflow-panel p-5 space-y-5">
<div>
<div className="workflow-kicker">{t('workflowDetail.session')}</div>
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.03em] text-ink">{t('workflowDetail.overview')}</h2>
</div>
<div className="space-y-5">
<SidebarRow label={t('workflowDetail.taskId')}>
<div className="break-all">{workflow.task_id}</div>
</SidebarRow>
<SidebarRow label={t('workflowDetail.runtime')}>
<div>{executionDurationLabel}</div>
<div className="text-xs leading-6 text-muted">
{iterationsLabel} · {totalStepLabel} · {actionCountLabel}
</div>
</SidebarRow>
<SidebarRow label={t('workflowDetail.window')}>
<div>{formatDate(workflow.start_time)}</div>
<div className="text-xs leading-6 text-muted">{t('workflowDetail.ended', { date: formatDate(workflow.end_time) })}</div>
</SidebarRow>
<SidebarRow label={t('workflowDetail.selectionMethod')}>
<div>{selectionMethodLabel}</div>
<div className="text-xs leading-6 text-muted">{selectedSkillLabel}</div>
</SidebarRow>
{enabledBackends.length > 0 ? (
<SidebarRow label={t('workflowDetail.enabledBackends')}>
<div className="flex flex-wrap gap-2 text-xs">
{enabledBackends.map((backend) => (
<WorkflowChip key={backend}>{humanizeToken(backend)}</WorkflowChip>
))}
</div>
</SidebarRow>
) : null}
{activityEntries.length > 0 ? (
<SidebarRow label={t('workflowDetail.backendActivity')}>
<div className="flex flex-wrap gap-2 text-xs">
{activityEntries.map(([backend, count]) => (
<WorkflowChip key={backend}>{`${humanizeToken(backend)} ${count}`}</WorkflowChip>
))}
</div>
</SidebarRow>
) : null}
</div>
</section>
</aside>
</section>
</div>
</div>
);
}

View file

@ -0,0 +1,130 @@
import { useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { workflowsApi, type WorkflowSummary } from '../api';
import EmptyState from '../components/EmptyState';
import { formatDate, formatInstruction } from '../utils/format';
export default function WorkflowsPage() {
const { t } = useTranslation();
const [workflows, setWorkflows] = useState<WorkflowSummary[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [query, setQuery] = useState('');
useEffect(() => {
let cancelled = false;
const load = async () => {
setLoading(true);
setError(null);
try {
const items = await workflowsApi.listWorkflows();
if (!cancelled) {
setWorkflows(items);
}
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : t('workflows.failedToLoad'));
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
};
void load();
return () => {
cancelled = true;
};
}, [t]);
const filtered = useMemo(() => {
const normalized = query.trim().toLowerCase();
const list = normalized
? workflows.filter((workflow) =>
workflow.task_name.toLowerCase().includes(normalized) ||
workflow.task_id.toLowerCase().includes(normalized) ||
workflow.instruction.toLowerCase().includes(normalized),
)
: [...workflows];
return list.sort((a, b) => new Date(b.start_time ?? 0).getTime() - new Date(a.start_time ?? 0).getTime());
}, [query, workflows]);
const averageSuccess = workflows.length > 0
? ((workflows.reduce((sum, item) => sum + item.success_rate, 0) / workflows.length) * 100).toFixed(1)
: '0.0';
return (
<div className="p-6 space-y-6">
<div className="flex items-end justify-between gap-4">
<div>
<h1 className="text-3xl font-bold font-serif">{t('workflows.title')}</h1>
</div>
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={t('workflows.searchPlaceholder')}
className="px-3 py-2 min-w-[320px]"
/>
</div>
<section className="grid grid-cols-2 gap-4">
<div className="p-4 space-y-2">
<div className="text-xs uppercase tracking-[0.16em] text-muted">{t('workflows.workflowSessions')}</div>
<div className="text-3xl font-bold font-serif leading-none">{workflows.length}</div>
<div className="text-xs text-muted">{t('workflows.scannedFrom')}</div>
</div>
<div className="p-4 space-y-2">
<div className="text-xs uppercase tracking-[0.16em] text-muted">{t('workflows.averageSuccess')}</div>
<div className="text-3xl font-bold font-serif leading-none">{averageSuccess}%</div>
<div className="text-xs text-muted">{t('workflows.meanSuccessRate')}</div>
</div>
</section>
{loading ? <div className="text-sm text-muted">{t('workflows.loadingWorkflows')}</div> : null}
{error ? <div className="text-sm text-danger">{error}</div> : null}
{!loading && !error && filtered.length === 0 ? (
<EmptyState title={t('workflows.noSessions')} description={t('workflows.noSessionsDesc')} />
) : null}
{!loading && !error && filtered.length > 0 ? (
<div className="grid grid-cols-2 gap-4">
{filtered.map((workflow) => (
<Link key={workflow.id} to={`/workflows/${encodeURIComponent(workflow.id)}`} className="record-card bg-surface block p-4 space-y-3 hover:border-primary transition-colors">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<div className="font-bold truncate">{workflow.task_name}</div>
</div>
<div className="text-right shrink-0">
<div className="text-lg font-bold font-serif">{(workflow.success_rate * 100).toFixed(1)}%</div>
<div className="text-xs text-muted">{t('common.success')}</div>
</div>
</div>
<div className="text-sm text-muted line-clamp-2">{formatInstruction(workflow.instruction, 220, t('format.noInstruction'))}</div>
<div className="grid grid-cols-3 gap-3 text-xs text-muted">
<div>{t('common.steps', { count: workflow.total_steps })}</div>
<div>{t('common.agentActions', { count: workflow.agent_action_count })}</div>
<div>{formatDate(workflow.start_time)}</div>
</div>
{workflow.selected_skills.length > 0 ? (
<div className="flex flex-wrap gap-2 text-xs">
{workflow.selected_skills.slice(0, 3).map((skillId, index) => (
<span key={`${skillId}-${index}`} title={skillId} className="tag inline-flex max-w-full items-center px-2 py-1">
<span className="block max-w-[220px] truncate">{skillId}</span>
</span>
))}
{workflow.selected_skills.length > 3 ? (
<span className="tag px-2 py-1 text-muted">
{t('workflows.more', { count: workflow.selected_skills.length - 3 })}
</span>
) : null}
</div>
) : null}
</Link>
))}
</div>
) : null}
</div>
);
}

View file

@ -0,0 +1,85 @@
export interface DiffLine {
type: 'add' | 'del' | 'ctx';
text: string;
}
export interface DiffHunk {
header: string;
lines: DiffLine[];
}
export interface DiffFile {
path: string;
hunks: DiffHunk[];
}
function hasRenderableHunks(file: DiffFile): boolean {
return file.hunks.some((hunk) => hunk.lines.length > 0);
}
export function parseDiff(raw: string | null | undefined): DiffFile[] {
if (!raw || typeof raw !== 'string' || raw.length === 0) {
return [];
}
const files: DiffFile[] = [];
let currentFile: DiffFile | null = null;
let awaitingNewFilePath = false;
const finalizeCurrentFile = () => {
if (currentFile && hasRenderableHunks(currentFile)) {
files.push(currentFile);
}
currentFile = null;
awaitingNewFilePath = false;
};
for (const line of raw.split('\n')) {
if (line.startsWith('--- a/')) {
finalizeCurrentFile();
currentFile = { path: line.slice(6), hunks: [] };
continue;
}
if (line === '--- /dev/null') {
finalizeCurrentFile();
awaitingNewFilePath = true;
continue;
}
if (line.startsWith('+++ b/')) {
if (awaitingNewFilePath || !currentFile) {
currentFile = { path: line.slice(6), hunks: [] };
}
awaitingNewFilePath = false;
continue;
}
if (line === '+++ /dev/null') {
awaitingNewFilePath = false;
continue;
}
if (line.startsWith('@@') && currentFile) {
currentFile.hunks.push({ header: line, lines: [] });
continue;
}
if (!currentFile || currentFile.hunks.length === 0) {
continue;
}
const hunk = currentFile.hunks[currentFile.hunks.length - 1];
if (line.startsWith('+')) {
hunk.lines.push({ type: 'add', text: line.slice(1) });
} else if (line.startsWith('-')) {
hunk.lines.push({ type: 'del', text: line.slice(1) });
} else if (line.startsWith(' ')) {
hunk.lines.push({ type: 'ctx', text: line.slice(1) });
}
}
finalizeCurrentFile();
return files;
}

View file

@ -0,0 +1,73 @@
export function formatPercent(value: number, digits = 1): string {
return `${(value * 100).toFixed(digits)}%`;
}
export function formatDate(value?: string | null): string {
if (!value) {
return '—';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
return new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
}).format(date);
}
export function truncate(value: string, max = 120): string {
if (value.length <= max) {
return value;
}
return `${value.slice(0, max)}`;
}
/**
* Shorten absolute file paths in instruction text for display.
*
* "/Users/foo/bar/project/src/components/Panel.tsx"
* "…/src/components/Panel.tsx"
*
* Keeps the last `keep` path segments so context is preserved.
*/
function shortenPaths(text: string, keep = 3): string {
// Match absolute paths: /Xxx/Yyy/.../file or dir
return text.replace(
/\/(?:Users|home|tmp|var|opt)\/[^\s,;)}\]]+/g,
(match) => {
const parts = match.split('/').filter(Boolean);
if (parts.length <= keep) return match;
return '…/' + parts.slice(-keep).join('/');
},
);
}
/**
* Format a raw instruction string for display.
*
* 1. Shortens long absolute file paths to last 3 segments
* 2. Collapses excessive whitespace / newlines
* 3. Trims to `maxLen` characters (with ellipsis)
*/
export function formatInstruction(
raw: string | null | undefined,
maxLen?: number,
fallback = 'No instruction captured',
): string {
if (!raw) return fallback;
let text = shortenPaths(raw);
// Collapse newlines → spaces, normalise whitespace
text = text.replace(/\n+/g, ' ').replace(/\s{2,}/g, ' ').trim();
if (maxLen && text.length > maxLen) {
text = text.slice(0, maxLen) + '…';
}
return text;
}

View file

@ -0,0 +1,115 @@
import type { Skill } from '../api';
export interface SkillClassSummary {
class_id: string;
representative: Skill;
versions: Skill[];
version_count: number;
active_count: number;
best_score: number;
average_score: number;
latest_updated: string;
origins: string[];
tags: string[];
total_selections: number;
}
function getUpdatedAtTimestamp(skill: Skill): number {
const parsed = Date.parse(skill.last_updated);
return Number.isFinite(parsed) ? parsed : 0;
}
function chooseRepresentative(versions: Skill[]): Skill {
return [...versions].sort((left, right) => {
if (left.is_active !== right.is_active) {
return left.is_active ? -1 : 1;
}
if (left.generation !== right.generation) {
return right.generation - left.generation;
}
if (left.score !== right.score) {
return right.score - left.score;
}
return getUpdatedAtTimestamp(right) - getUpdatedAtTimestamp(left);
})[0];
}
export function buildSkillClasses(skills: Skill[]): SkillClassSummary[] {
const skillsById = new Map(skills.map((skill) => [skill.skill_id, skill]));
const childrenByParent = new Map<string, string[]>();
skills.forEach((skill) => {
skill.parent_skill_ids.forEach((parentSkillId) => {
const children = childrenByParent.get(parentSkillId) ?? [];
children.push(skill.skill_id);
childrenByParent.set(parentSkillId, children);
});
});
const visited = new Set<string>();
const classes: SkillClassSummary[] = [];
for (const skill of skills) {
if (visited.has(skill.skill_id)) {
continue;
}
const stack = [skill.skill_id];
const versions: Skill[] = [];
while (stack.length > 0) {
const currentSkillId = stack.pop();
if (!currentSkillId || visited.has(currentSkillId)) {
continue;
}
const currentSkill = skillsById.get(currentSkillId);
if (!currentSkill) {
continue;
}
visited.add(currentSkillId);
versions.push(currentSkill);
currentSkill.parent_skill_ids.forEach((parentSkillId) => {
if (skillsById.has(parentSkillId) && !visited.has(parentSkillId)) {
stack.push(parentSkillId);
}
});
(childrenByParent.get(currentSkillId) ?? []).forEach((childSkillId) => {
if (!visited.has(childSkillId)) {
stack.push(childSkillId);
}
});
}
const representative = chooseRepresentative(versions);
const latestUpdated = [...versions]
.sort((left, right) => getUpdatedAtTimestamp(right) - getUpdatedAtTimestamp(left))[0]?.last_updated ?? representative.last_updated;
const tagSet = new Set<string>();
const originSet = new Set<string>();
let totalSelections = 0;
versions.forEach((version) => {
version.tags.forEach((tag) => tagSet.add(tag));
originSet.add(version.origin);
totalSelections += version.total_selections;
});
classes.push({
class_id: representative.skill_id,
representative,
versions,
version_count: versions.length,
active_count: versions.filter((version) => version.is_active).length,
best_score: Math.max(...versions.map((version) => version.score)),
average_score: versions.reduce((sum, version) => sum + version.score, 0) / versions.length,
latest_updated: latestUpdated,
origins: Array.from(originSet).sort(),
tags: Array.from(tagSet).sort(),
total_selections: totalSelections,
});
}
return classes;
}

1
apps/dashboard/src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference types="vite/client" />

View file

@ -0,0 +1,58 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
fontFamily: {
serif: 'var(--font-serif)',
sans: 'var(--font-sans)',
mono: 'var(--font-mono)',
},
colors: {
'bg-page': 'var(--color-bg-page)',
'surface': 'var(--color-surface)',
'primary': 'var(--color-primary)',
'ink': 'var(--color-ink)',
'muted': 'var(--color-muted)',
'accent': 'var(--color-accent)',
'danger': 'var(--color-danger)',
'oxide': 'var(--color-oxide)',
'teal': 'var(--color-teal)',
'gold': 'var(--color-gold)',
'red': 'var(--color-red)',
'diff-add': 'var(--color-diff-add)',
'diff-del': 'var(--color-diff-del)',
'border': 'var(--color-border)',
'border-dark': 'var(--color-border-dark)',
'mid-gray': 'var(--color-mid-gray)',
'blue': 'var(--color-blue)',
'green': 'var(--color-green)',
'paper': 'var(--color-paper)',
'warm-gray': 'var(--color-warm-gray)',
'sage': 'var(--color-sage)',
'lavender': 'var(--color-lavender)',
'sand': 'var(--color-sand)',
'selection': 'var(--color-selection)',
},
backgroundImage: {
'scanlines': 'linear-gradient(to bottom, transparent 50%, rgba(74, 59, 42, 0.03) 50%)',
'vignette': 'radial-gradient(circle at center, transparent 60%, rgba(74, 59, 42, 0.12) 120%)',
'noise': 'url("data:image/svg+xml,%3Csvg viewBox=\'0 0 200 200\' xmlns=\'http://www.w3.org/2000/svg\'%3E%3Cfilter id=\'noiseFilter\'%3E%3CfeTurbulence type=\'fractalNoise\' baseFrequency=\'0.8\' numOctaves=\'3\' stitchTiles=\'stitch\'/%3E%3C/filter%3E%3Crect width=\'100%25\' height=\'100%25\' filter=\'url(%23noiseFilter)\' opacity=\'0.15\'/%3E%3C/svg%3E")',
},
borderRadius: {
DEFAULT: 'var(--radius)',
chip: 'var(--radius-chip)',
card: 'var(--radius-card)',
'card-sm': 'var(--radius-card-sm)',
},
boxShadow: {
'button': '4px 6px 0px 0px #4A3B2A',
'soft': 'var(--shadow-soft)',
},
},
},
plugins: [],
}

View file

@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View file

@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

View file

@ -0,0 +1,35 @@
import { defineConfig, loadEnv } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, __dirname, '');
const port = Number(env.VITE_PORT || 3888);
const host = env.VITE_HOST || '127.0.0.1';
const apiProxyTarget = env.VITE_API_PROXY_TARGET || 'http://127.0.0.1:7788';
const proxy = {
'/api': {
target: apiProxyTarget,
changeOrigin: true,
},
};
return {
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
host,
port,
proxy,
},
preview: {
host,
port,
proxy,
},
};
});

628
apps/tui/package-lock.json generated Normal file
View file

@ -0,0 +1,628 @@
{
"name": "openspace-tui",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openspace-tui",
"version": "0.1.0",
"dependencies": {
"ink": "^5.1.0",
"react": "^18.3.1"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@types/react": "^18.3.12",
"typescript": "^5.7.0"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@alcalzone/ansi-tokenize": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.1.3.tgz",
"integrity": "sha512-3yWxPTq3UQ/FY9p1ErPxIyfT64elWaMvM9lIHnaqpyft63tkxodF5aUElYHrdisWve5cETkh1+KBw1yJuW0aRw==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^6.2.1",
"is-fullwidth-code-point": "^4.0.0"
},
"engines": {
"node": ">=14.13.1"
}
},
"node_modules/@types/node": {
"version": "22.19.17",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz",
"integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/@types/prop-types": {
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
"devOptional": true,
"license": "MIT"
},
"node_modules/@types/react": {
"version": "18.3.12",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.12.tgz",
"integrity": "sha512-D2wOSq/d6Agt28q7rSI3jhU7G6aiuzljDGZ2hTZHIkrTLUI+AF3WMeKkEZ9nN2fkBAlcktT6vcZjDFiIhMYEQw==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.0.2"
}
},
"node_modules/ansi-escapes": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz",
"integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==",
"license": "MIT",
"dependencies": {
"environment": "^1.0.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/ansi-regex": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
}
},
"node_modules/ansi-styles": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/auto-bind": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/auto-bind/-/auto-bind-5.0.1.tgz",
"integrity": "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==",
"license": "MIT",
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/chalk": {
"version": "5.6.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
"integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==",
"license": "MIT",
"engines": {
"node": "^12.17.0 || ^14.13 || >=16.0.0"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/cli-boxes": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz",
"integrity": "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/cli-cursor": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-4.0.0.tgz",
"integrity": "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==",
"license": "MIT",
"dependencies": {
"restore-cursor": "^4.0.0"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/cli-truncate": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-4.0.0.tgz",
"integrity": "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==",
"license": "MIT",
"dependencies": {
"slice-ansi": "^5.0.0",
"string-width": "^7.0.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/cli-truncate/node_modules/slice-ansi": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz",
"integrity": "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^6.0.0",
"is-fullwidth-code-point": "^4.0.0"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/slice-ansi?sponsor=1"
}
},
"node_modules/code-excerpt": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/code-excerpt/-/code-excerpt-4.0.0.tgz",
"integrity": "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==",
"license": "MIT",
"dependencies": {
"convert-to-spaces": "^2.0.1"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
}
},
"node_modules/convert-to-spaces": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz",
"integrity": "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==",
"license": "MIT",
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"devOptional": true,
"license": "MIT"
},
"node_modules/emoji-regex": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
"integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
"license": "MIT"
},
"node_modules/environment": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz",
"integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/es-toolkit": {
"version": "1.45.1",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz",
"integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==",
"license": "MIT",
"workspaces": [
"docs",
"benchmarks"
]
},
"node_modules/escape-string-regexp": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz",
"integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/get-east-asian-width": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz",
"integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/indent-string": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz",
"integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/ink": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/ink/-/ink-5.2.1.tgz",
"integrity": "sha512-BqcUyWrG9zq5HIwW6JcfFHsIYebJkWWb4fczNah1goUO0vv5vneIlfwuS85twyJ5hYR/y18FlAYUxrO9ChIWVg==",
"license": "MIT",
"dependencies": {
"@alcalzone/ansi-tokenize": "^0.1.3",
"ansi-escapes": "^7.0.0",
"ansi-styles": "^6.2.1",
"auto-bind": "^5.0.1",
"chalk": "^5.3.0",
"cli-boxes": "^3.0.0",
"cli-cursor": "^4.0.0",
"cli-truncate": "^4.0.0",
"code-excerpt": "^4.0.0",
"es-toolkit": "^1.22.0",
"indent-string": "^5.0.0",
"is-in-ci": "^1.0.0",
"patch-console": "^2.0.0",
"react-reconciler": "^0.29.0",
"scheduler": "^0.23.0",
"signal-exit": "^3.0.7",
"slice-ansi": "^7.1.0",
"stack-utils": "^2.0.6",
"string-width": "^7.2.0",
"type-fest": "^4.27.0",
"widest-line": "^5.0.0",
"wrap-ansi": "^9.0.0",
"ws": "^8.18.0",
"yoga-layout": "~3.2.1"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@types/react": ">=18.0.0",
"react": ">=18.0.0",
"react-devtools-core": "^4.19.1"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"react-devtools-core": {
"optional": true
}
}
},
"node_modules/ink/node_modules/react-reconciler": {
"version": "0.29.2",
"resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.29.2.tgz",
"integrity": "sha512-zZQqIiYgDCTP/f1N/mAR10nJGrPD2ZR+jDSEsKWJHYC7Cm2wodlwbR3upZRdC3cjIjSlTLNVyO7Iu0Yy7t2AYg==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
},
"engines": {
"node": ">=0.10.0"
},
"peerDependencies": {
"react": "^18.3.1"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz",
"integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-in-ci": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-in-ci/-/is-in-ci-1.0.0.tgz",
"integrity": "sha512-eUuAjybVTHMYWm/U+vBO1sY/JOCgoPCXRxzdju0K+K0BiGW0SChEL1MLC0PoCIR1OlPo5YAp8HuQoUlsWEICwg==",
"license": "MIT",
"bin": {
"is-in-ci": "cli.js"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"license": "MIT"
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"license": "MIT",
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
},
"bin": {
"loose-envify": "cli.js"
}
},
"node_modules/mimic-fn": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz",
"integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/onetime": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz",
"integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==",
"license": "MIT",
"dependencies": {
"mimic-fn": "^2.1.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/patch-console": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/patch-console/-/patch-console-2.0.0.tgz",
"integrity": "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==",
"license": "MIT",
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
}
},
"node_modules/react": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/restore-cursor": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-4.0.0.tgz",
"integrity": "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==",
"license": "MIT",
"dependencies": {
"onetime": "^5.1.0",
"signal-exit": "^3.0.2"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/scheduler": {
"version": "0.23.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0"
}
},
"node_modules/signal-exit": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
"license": "ISC"
},
"node_modules/slice-ansi": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz",
"integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^6.2.1",
"is-fullwidth-code-point": "^5.0.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/chalk/slice-ansi?sponsor=1"
}
},
"node_modules/slice-ansi/node_modules/is-fullwidth-code-point": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz",
"integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==",
"license": "MIT",
"dependencies": {
"get-east-asian-width": "^1.3.1"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/stack-utils": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz",
"integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==",
"license": "MIT",
"dependencies": {
"escape-string-regexp": "^2.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/string-width": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
"integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^10.3.0",
"get-east-asian-width": "^1.0.0",
"strip-ansi": "^7.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/strip-ansi": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^6.2.2"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
"node_modules/type-fest": {
"version": "4.41.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
"integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
"license": "(MIT OR CC0-1.0)",
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/widest-line": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/widest-line/-/widest-line-5.0.0.tgz",
"integrity": "sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==",
"license": "MIT",
"dependencies": {
"string-width": "^7.0.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/wrap-ansi": {
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
"integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^6.2.1",
"string-width": "^7.0.0",
"strip-ansi": "^7.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/ws": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/yoga-layout": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz",
"integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==",
"license": "MIT"
}
}
}

27
apps/tui/package.json Normal file
View file

@ -0,0 +1,27 @@
{
"name": "openspace-tui",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "dist/index.js",
"engines": {
"node": ">=20.0.0"
},
"scripts": {
"build": "tsc",
"copy:packaged": "node ../../scripts/copy-dist-to-packaged.mjs dist ../../openspace/packaged/tui --node-modules node_modules --package-json package.json",
"build:packaged": "npm run build && npm run copy:packaged",
"dev": "tsc --watch",
"start": "node dist/index.js",
"test": "node --test dist/bridge/*.test.js dist/commands/*.test.js dist/keybindings/*.test.js dist/state/*.test.js dist/utils/*.test.js"
},
"dependencies": {
"ink": "^5.1.0",
"react": "^18.3.1"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@types/react": "^18.3.12",
"typescript": "^5.7.0"
}
}

View file

@ -0,0 +1,48 @@
/**
* Minimal TS "agent" that reads NDJSON from stdin, echoes every message
* back with type prefixed by "echo_", and also sends a startup greeting.
*
* Used by the Python integration test to verify the IPC round-trip.
*/
import { createInterface } from "node:readline";
import { ndjsonParse, ndjsonSafeStringify } from "../ndjson.js";
function write(obj: unknown): void {
process.stdout.write(ndjsonSafeStringify(obj) + "\n");
}
write({
type: "status_update",
data: { model: "test-model", session_id: "test-session" },
timestamp: Date.now(),
});
const rl = createInterface({ input: process.stdin, crlfDelay: Infinity });
for await (const line of rl) {
const msg = ndjsonParse<{
type: string;
data: unknown;
timestamp?: number;
}>(line);
if (!msg) continue;
if (msg.type === "permission_request") {
write({
type: "permission_response",
data: {
tool_use_id: (msg.data as Record<string, unknown>).tool_use_id,
decision: "allow",
},
timestamp: Date.now(),
});
continue;
}
write({
type: `echo_${msg.type}`,
data: msg.data,
timestamp: Date.now(),
});
}

View file

@ -0,0 +1,81 @@
import type { IPCMessage, PermissionRequestData } from "../protocol.js";
import { StructuredIO } from "../structuredIO.js";
const io = new StructuredIO();
const receivedTypes: string[] = [];
const permissionCounts = new Map<string, number>();
function send(message: IPCMessage | { type: string; data: unknown; timestamp?: number }): void {
process.stdout.write(JSON.stringify({
...message,
timestamp: message.timestamp ?? Date.now(),
}) + "\n");
}
function emitReport(type: string): void {
send({
type,
data: {
received_types: [...receivedTypes],
permission_counts: Object.fromEntries(permissionCounts.entries()),
},
});
}
process.stdout.write("not-json\n");
process.stdout.write("\n");
send({
type: "query",
data: {
text: "List the workspace status and explain any blockers.",
attachments: ["/tmp/spec.md", "/tmp/screenshot.png"],
},
});
for await (const message of io.receive()) {
receivedTypes.push(message.type);
if (message.type === "permission_request") {
const data = message.data as PermissionRequestData;
permissionCounts.set(data.tool_use_id, (permissionCounts.get(data.tool_use_id) ?? 0) + 1);
void io.waitForPermissionDecision(data).catch(() => undefined);
if (!data.tool_use_id.startsWith("hang-")) {
setTimeout(() => {
io.resolvePermission({
tool_use_id: data.tool_use_id,
decision: "allow",
});
}, 25);
}
continue;
}
if (message.type === "notification") {
const data = message.data as Record<string, unknown>;
if (data.title === "report-permissions") {
emitReport("permission_report");
}
continue;
}
if (message.type === "task_complete") {
emitReport("scenario_complete");
continue;
}
if (message.type === "cancel") {
send({
type: "cancel_ack",
data: {
pending_count: io.pendingPermissions.size,
received_types: [...receivedTypes],
},
});
io.rejectAllPending("Cancelled by core");
break;
}
}
io.close();

View file

@ -0,0 +1,128 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
buildAskUserQuestionAllowResponse,
buildAskUserQuestionDenyResponse,
getAskUserQuestionQuestions,
isAskUserQuestionRequest,
} from "./askUserQuestionState.js";
import type { PermissionRequestData } from "./protocol.js";
function makeRequest(
overrides: Partial<PermissionRequestData> = {},
): PermissionRequestData {
return {
tool_use_id: "ask-1",
tool_name: "ask_user_question",
tool_input: {
questions: [
{
question: "Pick a branch",
options: [
{ label: "A", preview: "<p>A preview</p>" },
{ label: "B" },
],
},
],
annotations: {
"Pick a branch": {
existing: "kept",
},
"Earlier question": "legacy note",
},
},
response_channel: "tool_permission_response",
options: [{ option_id: "allow_once", label: "Allow once" }],
...overrides,
};
}
test("detects AskUserQuestion requests from interaction and tool aliases", () => {
assert.equal(
isAskUserQuestionRequest(
makeRequest({ tool_name: "Bash", interaction: "ask_user_question" }),
),
true,
);
assert.equal(
isAskUserQuestionRequest(makeRequest({ tool_name: "AskUserQuestion" })),
true,
);
assert.equal(
isAskUserQuestionRequest(makeRequest({ tool_name: "Bash" })),
false,
);
});
test("coerces valid questions and caps the native panel payload at four", () => {
const questions = getAskUserQuestionQuestions(
makeRequest({
questions: [
{
question: "One",
options: [{ label: "A" }, { label: "B" }],
},
{
question: "",
options: [{ label: "A" }, { label: "B" }],
},
{
question: "Two",
options: [{ label: "C" }, { label: "D" }],
multiSelect: true,
},
{
question: "Three",
options: [{ label: "E" }, { label: "F" }],
},
{
question: "Four",
options: [{ label: "G" }, { label: "H" }],
},
{
question: "Five",
options: [{ label: "I" }, { label: "J" }],
},
],
}),
);
assert.deepEqual(
questions.map(question => question.question),
["One", "Two", "Three", "Four"],
);
assert.equal(questions[1]?.multiSelect, true);
});
test("allow response merges answers, previews, notes, and existing annotations", () => {
const request = makeRequest();
const response = buildAskUserQuestionAllowResponse(
request,
{ "Pick a branch": "A" },
{ "Pick a branch": "take this path" },
);
assert.equal(response.tool_use_id, "ask-1");
assert.equal(response.option_id, "allow_once");
assert.deepEqual(response.updated_input?.answers, {
"Pick a branch": "A",
});
assert.deepEqual(response.updated_input?.annotations, {
"Pick a branch": {
existing: "kept",
preview: "<p>A preview</p>",
notes: "take this path",
},
"Earlier question": "legacy note",
});
});
test("deny response is fail-closed and does not include updated input", () => {
const response = buildAskUserQuestionDenyResponse(makeRequest(), "cancelled");
assert.deepEqual(response, {
tool_use_id: "ask-1",
option_id: "deny",
message: "cancelled",
});
});

View file

@ -0,0 +1,170 @@
import type {
AskUserQuestionData,
AskUserQuestionOptionData,
PermissionRequestData,
ToolPermissionResponseData,
} from "./protocol.js";
export type AskUserQuestionAnswers = Record<string, string>;
export type AskUserQuestionNotes = Record<string, string>;
function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
return value as Record<string, unknown>;
}
function nonEmptyString(value: unknown): string | null {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
function coerceOption(value: unknown): AskUserQuestionOptionData | null {
const record = asRecord(value);
const label = nonEmptyString(record?.label);
if (!record || !label) {
return null;
}
return {
label,
description: nonEmptyString(record.description) ?? undefined,
preview: nonEmptyString(record.preview) ?? undefined,
};
}
function coerceQuestion(value: unknown): AskUserQuestionData | null {
const record = asRecord(value);
const question = nonEmptyString(record?.question);
const rawOptions = Array.isArray(record?.options) ? record.options : [];
if (!record || !question || rawOptions.length === 0) {
return null;
}
const options = rawOptions
.map(coerceOption)
.filter((option): option is AskUserQuestionOptionData => option !== null);
if (options.length === 0) {
return null;
}
return {
header: nonEmptyString(record.header) ?? undefined,
question,
options,
multiSelect: record.multiSelect === true,
};
}
export function getAskUserQuestionQuestions(
request: PermissionRequestData,
): AskUserQuestionData[] {
const source = Array.isArray(request.questions)
? request.questions
: Array.isArray(request.tool_input.questions)
? request.tool_input.questions
: [];
return source
.map(coerceQuestion)
.filter((question): question is AskUserQuestionData => question !== null)
.slice(0, 4);
}
export function isAskUserQuestionRequest(
request: PermissionRequestData,
): boolean {
if (request.interaction === "ask_user_question") {
return true;
}
const toolName = request.tool_name.toLowerCase();
return (
toolName === "askuserquestion" ||
toolName === "ask_user_question" ||
toolName === "ask-user-question"
);
}
function cloneExistingAnnotations(
input: Record<string, unknown>,
): Record<string, unknown> {
const annotations = asRecord(input.annotations);
if (!annotations) {
return {};
}
return Object.fromEntries(
Object.entries(annotations).map(([key, value]) => {
const record = asRecord(value);
return [key, record ? { ...record } : value];
}),
);
}
export function buildAskUserQuestionUpdatedInput(
request: PermissionRequestData,
answers: AskUserQuestionAnswers,
notes: AskUserQuestionNotes = {},
): Record<string, unknown> {
const questions = getAskUserQuestionQuestions(request);
const annotations = cloneExistingAnnotations(request.tool_input);
for (const question of questions) {
const answer = answers[question.question];
const selectedOption = answer
? question.options.find(option => option.label === answer)
: undefined;
const note = notes[question.question]?.trim();
const currentValue = annotations[question.question];
const current = asRecord(currentValue);
const next: Record<string, unknown> =
currentValue === undefined
? {}
: current
? { ...current }
: { value: currentValue };
if (selectedOption?.preview) {
next.preview = selectedOption.preview;
}
if (note) {
next.notes = note;
}
if (Object.keys(next).length > 0) {
annotations[question.question] = next;
}
}
const nextInput: Record<string, unknown> = {
...request.tool_input,
answers,
};
if (Object.keys(annotations).length > 0) {
nextInput.annotations = annotations;
}
return nextInput;
}
export function buildAskUserQuestionAllowResponse(
request: PermissionRequestData,
answers: AskUserQuestionAnswers,
notes: AskUserQuestionNotes = {},
): ToolPermissionResponseData {
return {
tool_use_id: request.tool_use_id,
option_id: "allow_once",
updated_input: buildAskUserQuestionUpdatedInput(request, answers, notes),
};
}
export function buildAskUserQuestionDenyResponse(
request: PermissionRequestData,
message = "Question prompt cancelled by user.",
): ToolPermissionResponseData {
return {
tool_use_id: request.tool_use_id,
option_id: "deny",
message,
};
}

View file

@ -0,0 +1,25 @@
export { StructuredIO } from "./structuredIO.js";
export type { StructuredIOOptions } from "./structuredIO.js";
export { ndjsonSafeStringify, ndjsonParse } from "./ndjson.js";
export type {
EventType,
TuiToCoreMsgType,
CoreToTuiMsgType,
IPCMessage,
EventDataMap,
QueryData,
CancelData,
PermissionResponseData,
PermissionRequestData,
LLMStartData,
LLMTokenData,
LLMCompleteData,
ToolStartData,
ToolProgressData,
ToolCompleteData,
ToolErrorData,
StatusUpdateData,
NotificationData,
} from "./protocol.js";

View file

@ -0,0 +1,33 @@
// JSON.stringify emits U+2028/U+2029 raw (valid per ECMA-404). When the
// output is a single NDJSON line, any receiver that uses JavaScript
// line-terminator semantics (ECMA-262 §11.3 — \n \r U+2028 U+2029) to
// split the stream will cut the JSON mid-string. The \uXXXX form is
// equivalent JSON but can never be mistaken for a line terminator.
const JS_LINE_TERMINATORS = /\u2028|\u2029/g;
function escapeJsLineTerminators(json: string): string {
return json.replace(
JS_LINE_TERMINATORS,
(c) => (c === "\u2028" ? "\\u2028" : "\\u2029"),
);
}
/**
* JSON.stringify for one-message-per-line transports. Escapes U+2028
* LINE SEPARATOR and U+2029 PARAGRAPH SEPARATOR so the serialized output
* cannot be broken by a line-splitting receiver.
*/
export function ndjsonSafeStringify(value: unknown): string {
return escapeJsLineTerminators(JSON.stringify(value));
}
/**
* Parse a single NDJSON line. Returns undefined on empty/whitespace-only
* lines instead of throwing.
*/
export function ndjsonParse<T = unknown>(line: string): T | undefined {
const trimmed = line.trim();
if (!trimmed) return undefined;
return JSON.parse(trimmed) as T;
}

View file

@ -0,0 +1,72 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
buildPendingSandboxRequest,
buildPendingWorkerRequest,
buildWorkerSandboxQueue,
getAllowAlwaysLabel,
getPermissionRequestSummary,
} from "./permissionRequestState.js";
import type { PermissionRequestData } from "./protocol.js";
test("permission request helpers classify worker network requests", () => {
const request: PermissionRequestData = {
tool_use_id: "perm-worker-network",
tool_name: "web_fetch",
tool_input: { url: "https://api.github.com/repos/openai/openai-python" },
request_kind: "network",
host: "api.github.com",
origin: "worker",
agent_id: "worker-1",
agent_name: "reviewer",
agent_color: "cyan",
};
assert.deepEqual(buildPendingSandboxRequest(request), {
requestId: "perm-worker-network",
host: "api.github.com",
requestKind: "network",
});
assert.deepEqual(buildPendingWorkerRequest(request), {
toolName: "web_fetch",
toolUseId: "perm-worker-network",
description:
"Worker reviewer requests network access to api.github.com via web_fetch",
workerId: "worker-1",
workerName: "reviewer",
workerColor: "cyan",
host: "api.github.com",
requestKind: "network",
});
assert.deepEqual(buildWorkerSandboxQueue([request]), [
{
requestId: "perm-worker-network",
workerId: "worker-1",
workerName: "reviewer",
workerColor: "cyan",
host: "api.github.com",
createdAt: 0,
},
]);
assert.equal(
getPermissionRequestSummary(request),
"Worker reviewer requests network access to api.github.com via web_fetch",
);
assert.equal(getAllowAlwaysLabel(request), "allow always for api.github.com");
});
test("permission request helpers fall back to generic tool prompts", () => {
const request: PermissionRequestData = {
tool_use_id: "perm-tool",
tool_name: "bash",
tool_input: { command: "pwd" },
};
assert.equal(buildPendingSandboxRequest(request), null);
assert.equal(buildPendingWorkerRequest(request), null);
assert.equal(
getPermissionRequestSummary(request),
"Primary session requests permission for bash",
);
assert.equal(getAllowAlwaysLabel(request), "allow always for bash");
});

View file

@ -0,0 +1,145 @@
import type { PermissionRequestData } from "./protocol.js";
export type WorkerPermissionQueueItem = {
requestId: string;
workerId: string;
workerName: string;
workerColor?: string;
host: string;
createdAt: number;
};
export type PendingSandboxPermissionState = {
requestId: string;
host: string;
requestKind: "network" | "sandbox";
} | null;
export type PendingWorkerPermissionState = {
toolName: string;
toolUseId: string;
description: string;
workerId: string;
workerName: string;
workerColor?: string;
host?: string;
requestKind: "tool" | "network" | "sandbox";
} | null;
function nonEmptyString(value: unknown): string | null {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
export function isWorkerPermissionRequest(
request: PermissionRequestData | null | undefined,
): request is PermissionRequestData {
if (!request) {
return false;
}
if (request.origin === "worker") {
return true;
}
const agentId = nonEmptyString(request.agent_id);
return agentId !== null && agentId !== "primary";
}
export function isSandboxPermissionRequest(
request: PermissionRequestData | null | undefined,
): request is PermissionRequestData & {
request_kind: "network" | "sandbox";
} {
return request?.request_kind === "network" || request?.request_kind === "sandbox";
}
export function getPermissionRequestSummary(
request: PermissionRequestData,
): string {
if (request.description?.trim()) {
return request.description.trim();
}
const workerName = request.agent_name ?? request.agent_id;
const prefix =
isWorkerPermissionRequest(request) && workerName
? `Worker ${workerName} `
: "";
if (request.request_kind === "network" && request.host) {
return `${prefix}requests network access to ${request.host} via ${request.tool_name}`;
}
if (request.request_kind === "sandbox" && request.host) {
return `${prefix}requests sandbox access to ${request.host} via ${request.tool_name}`;
}
if (request.request_kind === "sandbox") {
return `${prefix}requests sandbox access via ${request.tool_name}`;
}
return prefix
? `${prefix}requests permission for ${request.tool_name}`
: `Primary session requests permission for ${request.tool_name}`;
}
export function getAllowAlwaysLabel(
request: PermissionRequestData,
): string {
if (request.host) {
return `allow always for ${request.host}`;
}
return `allow always for ${request.tool_name}`;
}
export function buildPendingSandboxRequest(
request: PermissionRequestData | null | undefined,
): PendingSandboxPermissionState {
if (!isSandboxPermissionRequest(request)) {
return null;
}
return {
requestId: request.tool_use_id,
host: request.host ?? request.tool_name,
requestKind: request.request_kind,
};
}
export function buildPendingWorkerRequest(
request: PermissionRequestData | null | undefined,
): PendingWorkerPermissionState {
if (!isWorkerPermissionRequest(request)) {
return null;
}
const workerId = nonEmptyString(request.agent_id) ?? "worker";
const workerName = nonEmptyString(request.agent_name) ?? workerId;
return {
toolName: request.tool_name,
toolUseId: request.tool_use_id,
description: getPermissionRequestSummary(request),
workerId,
workerName,
workerColor: nonEmptyString(request.agent_color) ?? undefined,
host: nonEmptyString(request.host) ?? undefined,
requestKind: request.request_kind ?? "tool",
};
}
export function buildWorkerSandboxQueue(
requests: PermissionRequestData[],
): WorkerPermissionQueueItem[] {
return requests
.filter(
request =>
isWorkerPermissionRequest(request) &&
(request.host !== undefined || request.request_kind === "sandbox"),
)
.map((request, index) => {
const workerId = nonEmptyString(request.agent_id) ?? "worker";
const workerName = nonEmptyString(request.agent_name) ?? workerId;
return {
requestId: request.tool_use_id,
workerId,
workerName,
workerColor: nonEmptyString(request.agent_color) ?? undefined,
host: nonEmptyString(request.host) ?? request.tool_name,
createdAt: index,
};
});
}

View file

@ -0,0 +1,172 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
CORE_TO_TUI_MSG_TYPES,
TUI_TO_CORE_MSG_TYPES,
isKnownEventType,
isToolPermissionPromptForCancel,
makeProtocolWarningMessage,
} from "./protocol.js";
import type {
AutoDreamEventData,
CompactEventData,
EventDataMap,
IPCMessage,
MemoryExtractionCompleteData,
MemoryExtractionErrorData,
MemoryExtractionStartData,
MemoryLoggedEventData,
MemorySavedEventData,
MemorySelectorData,
TokenWarningEventData,
ToolPermissionAskData,
ToolPermissionCancelData,
ToolPermissionResponseData,
} from "./protocol.js";
type IsEqual<Left, Right> =
(<T>() => T extends Left ? 1 : 2) extends
(<T>() => T extends Right ? 1 : 2)
? true
: false;
type Assert<T extends true> = T;
const _toolPermissionPayloadAssertions: [
Assert<IsEqual<EventDataMap["tool_permission_ask"], ToolPermissionAskData>>,
Assert<IsEqual<IPCMessage<"tool_permission_ask">["data"], ToolPermissionAskData>>,
Assert<IsEqual<EventDataMap["tool_permission_response"], ToolPermissionResponseData>>,
Assert<IsEqual<IPCMessage<"tool_permission_response">["data"], ToolPermissionResponseData>>,
Assert<IsEqual<EventDataMap["tool_permission_cancel"], ToolPermissionCancelData>>,
Assert<IsEqual<IPCMessage<"tool_permission_cancel">["data"], ToolPermissionCancelData>>,
Assert<IsEqual<EventDataMap["compact_start"], CompactEventData>>,
Assert<IsEqual<EventDataMap["compact_complete"], CompactEventData>>,
Assert<IsEqual<EventDataMap["token_warning"], TokenWarningEventData>>,
Assert<IsEqual<EventDataMap["memory_selector"], MemorySelectorData>>,
Assert<IsEqual<EventDataMap["memory_saved"], MemorySavedEventData>>,
Assert<IsEqual<EventDataMap["memory_logged"], MemoryLoggedEventData>>,
Assert<IsEqual<EventDataMap["memory_extraction_start"], MemoryExtractionStartData>>,
Assert<IsEqual<EventDataMap["memory_extraction_complete"], MemoryExtractionCompleteData>>,
Assert<IsEqual<EventDataMap["memory_extraction_error"], MemoryExtractionErrorData>>,
Assert<IsEqual<EventDataMap["auto_dream_start"], AutoDreamEventData>>,
Assert<IsEqual<EventDataMap["auto_dream_progress"], AutoDreamEventData>>,
Assert<IsEqual<EventDataMap["auto_dream_complete"], AutoDreamEventData>>,
Assert<IsEqual<EventDataMap["auto_dream_error"], AutoDreamEventData>>,
Assert<IsEqual<EventDataMap["auto_dream_cancelled"], AutoDreamEventData>>,
] = [
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
];
type EventManifest = {
tui_to_core: string[];
core_to_tui: string[];
payload_types: Record<string, string>;
payload_schemas: Record<string, unknown>;
};
function readEventManifest(): EventManifest {
const url = new URL(
"../../../../openspace/protocol/schema/events.json",
import.meta.url,
);
return JSON.parse(readFileSync(url, "utf8")) as EventManifest;
}
test("event type runtime lists match the shared manifest", () => {
const manifest = readEventManifest();
assert.deepEqual([...TUI_TO_CORE_MSG_TYPES], manifest.tui_to_core);
assert.deepEqual([...CORE_TO_TUI_MSG_TYPES], manifest.core_to_tui);
assert.equal(isKnownEventType("tool_permission_ask"), true);
assert.equal(isKnownEventType("background_housekeeping_idle"), true);
assert.equal(
isKnownEventType("background_housekeeping_cleanup_complete"),
true,
);
assert.equal(isKnownEventType("not_a_protocol_event"), false);
assert.ok(manifest.payload_schemas.tool_permission_response);
assert.ok(manifest.payload_schemas.resume_session);
});
test("unknown event strategy produces a unified warning event", () => {
const warning = makeProtocolWarningMessage(
"not_a_protocol_event",
"Unknown protocol event type",
);
assert.equal(warning.type, "notification");
assert.equal(warning.data.level, "warn");
assert.equal(warning.data.title, "Protocol warning");
assert.equal(warning.data.event_type, "not_a_protocol_event");
});
test("tool permission cancel prompt matching is idempotent", () => {
const cancel = {
tool_use_id: "abc-123",
reason: "timed out",
};
const queue = [
"tool-permission-abc-123-choice",
"tool-permission-abc-123-edit",
"tool-permission-other-choice",
"normal-prompt",
];
const dismissed = queue.filter(
promptId => !isToolPermissionPromptForCancel(promptId, cancel),
);
const dismissedAgain = dismissed.filter(
promptId => !isToolPermissionPromptForCancel(promptId, cancel),
);
assert.deepEqual(dismissed, [
"tool-permission-other-choice",
"normal-prompt",
]);
assert.deepEqual(dismissedAgain, dismissed);
});
test("tool permission cancel ignores empty ids", () => {
assert.equal(
isToolPermissionPromptForCancel(
"tool-permission-abc-123-choice",
{ tool_use_id: " " },
),
false,
);
});
test("tool permission cancel does not match prefix-colliding ids", () => {
assert.equal(
isToolPermissionPromptForCancel(
"tool-permission-abc-123-choice",
{ tool_use_id: "abc" },
),
false,
);
assert.equal(
isToolPermissionPromptForCancel(
"tool-permission-abc-123-edit",
{ tool_use_id: "abc-123" },
),
true,
);
});

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,154 @@
import assert from "node:assert/strict";
import test from "node:test";
import { PassThrough } from "node:stream";
import { ndjsonParse } from "./ndjson.js";
import { StructuredIO } from "./structuredIO.js";
test("send stamps a timestamp on outgoing messages", async () => {
const output = new PassThrough();
const input = new PassThrough();
const io = new StructuredIO({ stdin: input, stdout: output });
io.send({ type: "query", data: { text: "hello" } });
const line = output.read()?.toString("utf8") ?? "";
const parsed = ndjsonParse<{ type: string; timestamp: number; data: { text: string } }>(
line,
);
assert.ok(parsed);
assert.equal(parsed.type, "query");
assert.equal(parsed.data.text, "hello");
assert.equal(typeof parsed.timestamp, "number");
output.destroy();
input.destroy();
});
test("receive deduplicates permission requests by tool_use_id", async () => {
const output = new PassThrough();
const input = new PassThrough();
const io = new StructuredIO({ stdin: input, stdout: output });
const received: Array<{ type: string }> = [];
const reader = (async () => {
for await (const message of io.receive()) {
received.push(message);
}
})();
const line = JSON.stringify({
type: "permission_request",
data: {
tool_use_id: "dup-1",
tool_name: "bash",
tool_input: { command: "ls" },
},
timestamp: Date.now(),
});
input.write(`${line}\n`);
input.write(`${line}\n`);
input.end();
await reader;
assert.equal(received.length, 1);
assert.equal(io.seenToolUseIds.size, 1);
assert.ok(io.seenToolUseIds.has("dup-1"));
output.destroy();
input.destroy();
});
test("resolvePermission clears pending state and writes a response", async () => {
const output = new PassThrough();
const input = new PassThrough();
const io = new StructuredIO({ stdin: input, stdout: output });
const pending = io.waitForPermissionDecision({
tool_use_id: "perm-1",
tool_name: "bash",
tool_input: { command: "pwd" },
});
let line = "";
output.on("data", (chunk) => {
line += chunk.toString("utf8");
});
io.resolvePermission({
tool_use_id: "perm-1",
decision: "allow",
});
const result = await pending;
assert.ok("decision" in result);
assert.equal(result.decision, "allow");
assert.equal(io.pendingPermissions.size, 0);
const parsed = ndjsonParse<{ type: string; data: { tool_use_id: string } }>(
output.read()?.toString("utf8") ?? line,
);
assert.ok(parsed);
assert.equal(parsed.type, "permission_response");
assert.equal(parsed.data.tool_use_id, "perm-1");
output.destroy();
input.destroy();
});
test("resolveToolPermission clears pending state and writes a response", async () => {
const output = new PassThrough();
const input = new PassThrough();
const io = new StructuredIO({ stdin: input, stdout: output });
const pending = io.waitForPermissionDecision({
tool_use_id: "tool-perm-1",
tool_name: "bash",
tool_input: { command: "git status" },
response_channel: "tool_permission_response",
options: [{ option_id: "allow_once", label: "Allow" }],
});
let line = "";
output.on("data", (chunk) => {
line += chunk.toString("utf8");
});
io.resolveToolPermission({
tool_use_id: "tool-perm-1",
option_id: "allow_once",
});
const result = await pending;
assert.equal(result.tool_use_id, "tool-perm-1");
assert.equal(io.pendingPermissions.size, 0);
const parsed = ndjsonParse<{ type: string; data: { tool_use_id: string } }>(
output.read()?.toString("utf8") ?? line,
);
assert.ok(parsed);
assert.equal(parsed.type, "tool_permission_response");
assert.equal(parsed.data.tool_use_id, "tool-perm-1");
output.destroy();
input.destroy();
});
test("close rejects outstanding permission promises", async () => {
const input = new PassThrough();
const output = new PassThrough();
const io = new StructuredIO({ stdin: input, stdout: output });
const pending = io.waitForPermissionDecision({
tool_use_id: "perm-close",
tool_name: "bash",
tool_input: { command: "sleep 10" },
});
io.close();
await assert.rejects(pending, /StructuredIO closed/);
input.destroy();
output.destroy();
});

View file

@ -0,0 +1,282 @@
import { createInterface } from "node:readline";
import { ndjsonParse, ndjsonSafeStringify } from "./ndjson.js";
import { isKnownEventType, makeProtocolWarningMessage } from "./protocol.js";
import type {
EventType,
IPCMessage,
PermissionRequestData,
PermissionResponseData,
ToolPermissionResponseData,
} from "./protocol.js";
const MAX_RESOLVED_TOOL_USE_IDS = 1000;
const RECENT_MESSAGE_LIMIT = 200;
const RECEIVE_YIELD_EVERY_MESSAGES = 32;
const RECEIVE_YIELD_EVERY_MS = 8;
export const STRUCTURED_IO_SEQUENCE = Symbol("structured_io_sequence");
export type StructuredIOListener = (message: IPCMessage) => void;
type SequencedIPCMessage = IPCMessage & {
[STRUCTURED_IO_SEQUENCE]?: number;
};
type PermissionDecisionResponse =
| PermissionResponseData
| ToolPermissionResponseData;
export function getStructuredIOSequence(
message: IPCMessage,
): number | null {
return (
(message as SequencedIPCMessage)[STRUCTURED_IO_SEQUENCE] ??
null
);
}
export interface StructuredIOOptions {
stdin?: NodeJS.ReadableStream;
stdout?: NodeJS.WritableStream;
}
/**
* Bidirectional NDJSON IPC channel between the TS TUI (this process)
* and the Python Core (parent process).
*
* - Reads NDJSON from stdin (events sent by Python Core)
* - Writes NDJSON to stdout (events sent to Python Core)
* - Manages pending permission requests with Future-like resolution
* - Deduplicates tool_use_ids
*/
export class StructuredIO {
private readonly input: NodeJS.ReadableStream;
private readonly output: NodeJS.WritableStream;
private readonly listeners = new Set<StructuredIOListener>();
private readonly recentMessages: IPCMessage[] = [];
private sequence = 0;
/**
* Map of tool_use_id { resolve, reject } for outstanding
* permission_request events awaiting a permission_response from the TUI.
* Used on the TUI side to track which permission prompts are still pending.
*/
readonly pendingPermissions = new Map<
string,
{
data: PermissionRequestData;
resolve: (response: PermissionDecisionResponse) => void;
reject: (error: Error) => void;
}
>();
/**
* Set of tool_use_ids that have already been resolved. Prevents
* duplicate permission_request events from spawning multiple prompts.
*/
readonly seenToolUseIds = new Set<string>();
private closed = false;
constructor(opts?: StructuredIOOptions) {
this.input = opts?.stdin ?? process.stdin;
this.output = opts?.stdout ?? process.stdout;
}
subscribe(
listener: StructuredIOListener,
opts?: { replayRecent?: boolean },
): () => void {
this.listeners.add(listener);
if (opts?.replayRecent) {
for (const message of this.recentMessages) {
listener(message);
}
}
return () => {
this.listeners.delete(listener);
};
}
// ── Writing ───────────────────────────────────────────────────
send<T extends EventType>(message: IPCMessage<T>): void {
if (this.closed) return;
const stamped = { ...message, timestamp: message.timestamp ?? Date.now() };
const line = ndjsonSafeStringify(stamped) + "\n";
this.output.write(line);
}
// ── Reading ───────────────────────────────────────────────────
/**
* Async generator that yields parsed IPC messages from stdin.
* Terminates when stdin closes.
*/
async *receive(): AsyncGenerator<IPCMessage, void, undefined> {
const rl = createInterface({ input: this.input, crlfDelay: Infinity });
let messagesSinceYield = 0;
let lastYieldAt = Date.now();
const yieldToRendererIfNeeded = async (): Promise<void> => {
messagesSinceYield += 1;
if (
messagesSinceYield < RECEIVE_YIELD_EVERY_MESSAGES &&
Date.now() - lastYieldAt < RECEIVE_YIELD_EVERY_MS
) {
return;
}
messagesSinceYield = 0;
lastYieldAt = Date.now();
await new Promise<void>(resolve => setImmediate(resolve));
};
for await (const line of rl) {
const msg = ndjsonParse<IPCMessage>(line);
if (!msg || typeof msg.type !== "string" || msg.type.length === 0) {
const warning = makeProtocolWarningMessage(
"<missing>",
"Malformed protocol event",
);
this.emit(warning);
yield warning;
await yieldToRendererIfNeeded();
continue;
}
if (!isKnownEventType(msg.type)) {
const warning = makeProtocolWarningMessage(
msg.type,
"Unknown protocol event type",
);
this.emit(warning);
yield warning;
await yieldToRendererIfNeeded();
continue;
}
if (msg.type === "permission_request" || msg.type === "tool_permission_ask") {
const data = msg.data as PermissionRequestData;
if (this.seenToolUseIds.has(data.tool_use_id)) continue;
this.trackToolUseId(data.tool_use_id);
}
if (msg.type === "permission_response") {
const data = msg.data as PermissionResponseData;
this.resolvePermission(data);
}
if (msg.type === "cancel") {
this.rejectAllPending("Cancelled by core");
}
this.emit(msg);
yield msg;
await yieldToRendererIfNeeded();
}
this.close();
}
// ── Permission management ─────────────────────────────────────
/**
* Register an incoming permission_request and return a Promise that
* resolves when the TUI user makes a decision. The REPL screen should
* display the prompt and call `resolvePermission()` with the answer.
*/
waitForPermissionDecision(
data: PermissionRequestData,
): Promise<PermissionDecisionResponse> {
return new Promise<PermissionDecisionResponse>((resolve, reject) => {
this.pendingPermissions.set(data.tool_use_id, {
data,
resolve,
reject,
});
});
}
/**
* Resolve a pending permission prompt and send the response back to
* the Python Core.
*/
resolvePermission(response: PermissionResponseData): void {
this.finalizePermission(response);
this.send({ type: "permission_response", data: response });
}
/**
* Send a native tool-permission response for Core tool_permission_ask events.
*/
resolveToolPermission(response: ToolPermissionResponseData): void {
this.finalizePermission(response);
this.send({ type: "tool_permission_response", data: response });
}
/**
* Reject all pending permission requests (e.g. on cancel/shutdown).
*/
rejectAllPending(reason: string): void {
for (const [id, pending] of this.pendingPermissions) {
pending.reject(new Error(reason));
this.pendingPermissions.delete(id);
}
}
// ── Lifecycle ─────────────────────────────────────────────────
close(): void {
if (this.closed) return;
this.closed = true;
this.listeners.clear();
this.rejectAllPending("StructuredIO closed");
}
get isClosed(): boolean {
return this.closed;
}
// ── Internal helpers ──────────────────────────────────────────
private trackToolUseId(id: string): void {
this.seenToolUseIds.add(id);
if (this.seenToolUseIds.size > MAX_RESOLVED_TOOL_USE_IDS) {
const first = this.seenToolUseIds.values().next().value;
if (first !== undefined) {
this.seenToolUseIds.delete(first);
}
}
}
private finalizePermission(response: PermissionDecisionResponse): void {
const pending = this.pendingPermissions.get(response.tool_use_id);
if (!pending) return;
this.pendingPermissions.delete(response.tool_use_id);
pending.resolve(response);
}
private emit(message: IPCMessage): void {
const sequenced = message as SequencedIPCMessage;
if (sequenced[STRUCTURED_IO_SEQUENCE] === undefined) {
Object.defineProperty(sequenced, STRUCTURED_IO_SEQUENCE, {
value: ++this.sequence,
enumerable: false,
writable: false,
});
}
this.recentMessages.push(message);
if (this.recentMessages.length > RECENT_MESSAGE_LIMIT) {
this.recentMessages.shift();
}
for (const listener of this.listeners) {
listener(message);
}
}
}

View file

@ -0,0 +1,79 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
formatSlashCommandDetailText,
formatSlashCommandHelpText,
getCommandCompletions,
getSlashCommandDefinition,
parseSlashCommandInput,
} from "./registry.js";
type SlashCommandManifest = {
commands: Array<{
name: string;
handler: "local" | "core";
summary: string;
usage: string;
aliases?: string[];
category: string;
args?: Array<{
name: string;
required: boolean;
description: string;
}>;
tui_visible?: boolean;
}>;
};
function readSlashCommandManifest(): SlashCommandManifest {
const url = new URL(
"../../../../openspace/protocol/schema/slash_commands.json",
import.meta.url,
);
return JSON.parse(readFileSync(url, "utf8")) as SlashCommandManifest;
}
test("registry exposes migrated core commands in help and completion", () => {
const completions = getCommandCompletions("su").map(command => command.name);
assert.deepEqual(completions, ["summary"]);
const help = formatSlashCommandHelpText();
assert.match(help, /\/summary — Update session memory/);
assert.match(help, /\/effort — View or set reasoning effort/);
assert.match(help, /\/config \(\/settings\) — View or modify local TUI settings/);
});
test("TUI exposes shared core slash command metadata", () => {
const manifest = readSlashCommandManifest();
for (const command of manifest.commands.filter(command => command.handler === "core")) {
const definition = getSlashCommandDefinition(command.name);
assert.ok(definition, `missing ${command.name}`);
assert.equal(definition.summary, command.summary);
assert.equal(definition.usage, command.usage);
assert.deepEqual(definition.aliases ?? [], command.aliases ?? []);
assert.equal(definition.category, command.category);
assert.deepEqual(definition.args ?? [], command.args ?? []);
}
});
test("settings aliases to config and detailed help includes args", () => {
const parsed = parseSlashCommandInput("/settings theme light");
assert.equal(parsed?.command, "config");
assert.deepEqual(parsed?.args, ["theme", "light"]);
assert.equal(parsed?.definition?.handler, "core");
const definition = getSlashCommandDefinition("config");
assert.ok(definition);
assert.equal(definition.handler, "core");
const detail = formatSlashCommandDetailText(definition);
assert.match(detail, /\/config \[key\] \[value\]/);
assert.match(detail, /Aliases: \/settings/);
assert.match(detail, /key \(optional\)/);
});
test("permissions command is routed to core", () => {
const parsed = parseSlashCommandInput("/permissions");
assert.equal(parsed?.command, "permissions");
assert.equal(parsed?.definition?.handler, "core");
});

View file

@ -0,0 +1,885 @@
import type { SlashCommandData } from "../bridge/protocol.js";
import type { Command } from "../types/command.js";
import { getCommandName, isCommandEnabled } from "../types/command.js";
// <openspace-slash-commands:generated>
// Generated by python -m openspace.protocol.codegen --write
export type SlashCommandName =
| "help"
| "clear"
| "status"
| "compact"
| "plan"
| "summary"
| "history"
| "model"
| "effort"
| "tools"
| "skills"
| "save"
| "load"
| "exit"
| "doctor"
| "resume"
| "cost"
| "agent"
| "background"
| "agents"
| "tasks"
| "mcp"
| "permissions"
| "sandbox"
| "copy"
| "keybindings"
| "vim"
| "config"
| "theme"
| "review"
| "diff"
| "export"
| "init"
| "memory"
| "dream";
export type SlashCommandCategory =
| "session"
| "navigation"
| "tools"
| "display"
| "project";
export type SlashCommandArg = {
name: string;
required: boolean;
description: string;
};
export type SlashCommandDefinition = Command & {
name: SlashCommandName;
summary: string;
usage: string;
category: SlashCommandCategory;
args?: SlashCommandArg[];
hidden?: boolean;
implemented?: boolean;
};
export type ParsedSlashCommand = SlashCommandData & {
args: string[];
raw: string;
definition: SlashCommandDefinition | null;
isSupported: boolean;
};
const SLASH_COMMAND_REGISTRY: readonly SlashCommandDefinition[] = [
{
"handler": "local",
"availability": [
"local"
],
"implemented": true,
"name": "help",
"description": "Show available commands.",
"summary": "Show available commands.",
"usage": "/help [command]",
"category": "navigation",
"aliases": [
"h",
"?"
],
"args": [
{
"name": "command",
"required": false,
"description": "Command name for detailed help"
}
]
},
{
"handler": "local",
"availability": [
"local"
],
"implemented": true,
"name": "clear",
"description": "Clear screen and message history.",
"summary": "Clear screen and message history.",
"usage": "/clear",
"category": "navigation",
"aliases": [
"cls"
]
},
{
"handler": "local",
"availability": [
"local"
],
"implemented": true,
"name": "status",
"description": "Show session stats (tokens, cost, model, iterations).",
"summary": "Show session stats (tokens, cost, model, iterations).",
"usage": "/status",
"category": "session"
},
{
"handler": "core",
"availability": [
"core"
],
"implemented": true,
"name": "compact",
"description": "Manually trigger context compaction.",
"summary": "Manually trigger context compaction.",
"usage": "/compact [instructions]",
"category": "session",
"args": [
{
"name": "instructions",
"required": false,
"description": "Optional compaction instructions"
}
]
},
{
"handler": "core",
"availability": [
"core"
],
"implemented": true,
"name": "plan",
"description": "Enter plan mode for the next turn.",
"summary": "Enter plan mode for the next turn.",
"usage": "/plan",
"category": "session"
},
{
"handler": "core",
"availability": [
"core"
],
"implemented": true,
"name": "summary",
"description": "Update session memory for the active session.",
"summary": "Update session memory for this session.",
"usage": "/summary",
"category": "session"
},
{
"handler": "local",
"availability": [
"local"
],
"implemented": true,
"name": "history",
"description": "Show prompt command history.",
"summary": "Show prompt command history.",
"usage": "/history [count]",
"category": "session",
"args": [
{
"name": "count",
"required": false,
"description": "Number of entries to show"
}
]
},
{
"handler": "core",
"availability": [
"core"
],
"implemented": true,
"name": "model",
"description": "View or switch the current model.",
"summary": "View or switch the current model.",
"usage": "/model [name]",
"category": "tools",
"aliases": [
"m"
],
"args": [
{
"name": "name",
"required": false,
"description": "Model name to switch to"
}
]
},
{
"handler": "core",
"availability": [
"core"
],
"implemented": true,
"name": "effort",
"description": "View or set reasoning effort.",
"summary": "View or set reasoning effort.",
"usage": "/effort [low|medium|high|max|auto]",
"category": "tools",
"args": [
{
"name": "level",
"required": false,
"description": "low, medium, high, max, or auto"
}
]
},
{
"handler": "core",
"availability": [
"core"
],
"implemented": true,
"name": "tools",
"description": "List available tools and their status.",
"summary": "List available tools and their status.",
"usage": "/tools [filter]",
"category": "tools",
"args": [
{
"name": "filter",
"required": false,
"description": "Filter tools by name"
}
]
},
{
"handler": "core",
"availability": [
"core"
],
"implemented": true,
"name": "skills",
"description": "List available skills and review runtime overlay suggestions.",
"summary": "List skills or review runtime overlays.",
"usage": "/skills [filter] | /skills overlay review [skill_id] | /skills overlay approve|reject <skill_id> [field ...]",
"category": "tools",
"args": [
{
"name": "filter",
"required": false,
"description": "Filter skills, or use overlay review/approve/reject"
}
]
},
{
"handler": "core",
"availability": [
"core"
],
"implemented": true,
"name": "save",
"description": "Persist the current session snapshot.",
"summary": "Persist the current session snapshot.",
"usage": "/save [name]",
"category": "session",
"args": [
{
"name": "name",
"required": false,
"description": "Optional session name"
}
]
},
{
"handler": "core",
"availability": [
"core"
],
"implemented": true,
"name": "load",
"description": "Load a saved session by ID.",
"summary": "Load a saved session by ID.",
"usage": "/load <session>",
"category": "session",
"args": [
{
"name": "session",
"required": true,
"description": "Session name or ID to load"
}
]
},
{
"handler": "local",
"availability": [
"local"
],
"implemented": true,
"name": "exit",
"description": "Exit the TUI.",
"summary": "Exit the TUI.",
"usage": "/exit",
"category": "navigation",
"aliases": [
"q",
"quit"
]
},
{
"handler": "core",
"availability": [
"core"
],
"implemented": true,
"name": "doctor",
"description": "Run diagnostic checks.",
"summary": "Run diagnostic checks.",
"usage": "/doctor",
"category": "tools"
},
{
"handler": "core",
"availability": [
"core"
],
"implemented": true,
"name": "resume",
"description": "List sessions or restore a previous session.",
"summary": "List sessions or restore a previous session.",
"usage": "/resume [session-id]",
"category": "session",
"args": [
{
"name": "session-id",
"required": false,
"description": "Session ID to restore"
}
]
},
{
"handler": "core",
"availability": [
"core"
],
"implemented": true,
"name": "cost",
"description": "Show the current accumulated cost.",
"summary": "Show the current accumulated cost.",
"usage": "/cost",
"category": "session",
"aliases": [
"$$"
]
},
{
"handler": "local",
"availability": [
"local"
],
"implemented": true,
"name": "agent",
"description": "Send input to the viewed or specified background agent.",
"summary": "Send input to a background agent.",
"usage": "/agent [agent-id] <message>",
"category": "tools",
"args": [
{
"name": "agent-id",
"required": false,
"description": "Optional target agent id"
},
{
"name": "message",
"required": true,
"description": "Input to send"
}
]
},
{
"handler": "local",
"availability": [
"local"
],
"implemented": true,
"name": "background",
"description": "Control or refresh the background runtime.",
"summary": "Control background runtime state.",
"usage": "/background [start|stop|pause|resume|focus] [title|agent-id]",
"category": "session",
"aliases": [
"bg"
],
"args": [
{
"name": "action",
"required": false,
"description": "start, stop, pause, resume, or focus"
},
{
"name": "value",
"required": false,
"description": "Optional title for start or agent id for focus"
}
]
},
{
"handler": "local",
"availability": [
"local"
],
"implemented": true,
"name": "agents",
"description": "Toggle the agent runtime panel.",
"summary": "Open or close the agent runtime panel.",
"usage": "/agents",
"category": "tools"
},
{
"handler": "local",
"availability": [
"local"
],
"implemented": true,
"name": "tasks",
"description": "Toggle the tasks panel.",
"summary": "Open or close the tasks panel.",
"usage": "/tasks",
"category": "session"
},
{
"handler": "local",
"availability": [
"local"
],
"implemented": true,
"name": "mcp",
"description": "Toggle the MCP panel or reconnect a server.",
"summary": "Inspect MCP status or reconnect a server.",
"usage": "/mcp [reconnect <server>]",
"category": "tools",
"args": [
{
"name": "action",
"required": false,
"description": "Optional action such as reconnect"
},
{
"name": "server",
"required": false,
"description": "Server name to reconnect"
}
]
},
{
"handler": "core",
"availability": [
"core"
],
"implemented": true,
"name": "permissions",
"description": "Show permission mode and rules.",
"summary": "Inspect permission mode and rules.",
"usage": "/permissions",
"category": "display"
},
{
"handler": "core",
"availability": [
"core"
],
"implemented": true,
"name": "sandbox",
"description": "Inspect or update process sandbox settings.",
"summary": "Inspect or update sandbox settings.",
"usage": "/sandbox [status|doctor|enable|disable|toggle|exclude]",
"category": "display",
"args": [
{
"name": "action",
"required": false,
"description": "status, doctor, enable, disable, toggle, exclude, or unexclude"
},
{
"name": "value",
"required": false,
"description": "Optional mode, on/off value, or command pattern"
}
]
},
{
"handler": "local",
"availability": [
"local"
],
"implemented": true,
"name": "copy",
"description": "Copy the latest message or full transcript to the clipboard.",
"summary": "Copy transcript text to the clipboard.",
"usage": "/copy [last|all]",
"category": "display",
"args": [
{
"name": "scope",
"required": false,
"description": "last or all"
}
]
},
{
"handler": "local",
"availability": [
"local"
],
"implemented": true,
"name": "keybindings",
"description": "Show keybinding file location and load status.",
"summary": "Inspect keybinding configuration.",
"usage": "/keybindings",
"category": "display"
},
{
"handler": "local",
"availability": [
"local"
],
"implemented": true,
"name": "vim",
"description": "Show or change the current Vim input mode.",
"summary": "Inspect or toggle Vim mode.",
"usage": "/vim [insert|normal|toggle]",
"category": "display",
"args": [
{
"name": "mode",
"required": false,
"description": "insert, normal, or toggle"
}
]
},
{
"handler": "core",
"availability": [
"core"
],
"implemented": true,
"name": "config",
"description": "View or modify local TUI settings.",
"summary": "View or modify local TUI settings.",
"usage": "/config [key] [value]",
"category": "display",
"aliases": [
"settings"
],
"args": [
{
"name": "key",
"required": false,
"description": "Setting key"
},
{
"name": "value",
"required": false,
"description": "New value"
}
]
},
{
"handler": "local",
"availability": [
"local"
],
"implemented": true,
"name": "theme",
"description": "Switch color theme.",
"summary": "Switch color theme.",
"usage": "/theme [name]",
"category": "display",
"args": [
{
"name": "name",
"required": false,
"description": "Theme name to apply"
}
]
},
{
"handler": "core",
"availability": [
"core"
],
"implemented": true,
"name": "review",
"description": "Start a code review on staged changes.",
"summary": "Start a code review on staged changes.",
"usage": "/review [path]",
"category": "project",
"aliases": [
"cr"
],
"args": [
{
"name": "path",
"required": false,
"description": "Limit review to a specific path"
}
]
},
{
"handler": "core",
"availability": [
"core"
],
"implemented": true,
"name": "diff",
"description": "Show diff of recent file changes.",
"summary": "Show diff of recent file changes.",
"usage": "/diff [path]",
"category": "project",
"args": [
{
"name": "path",
"required": false,
"description": "Path to diff"
}
]
},
{
"handler": "core",
"availability": [
"core"
],
"implemented": true,
"name": "export",
"description": "Export conversation to file.",
"summary": "Export conversation to file.",
"usage": "/export [format] [path]",
"category": "session",
"args": [
{
"name": "format",
"required": false,
"description": "Output format (md, json, txt)"
},
{
"name": "path",
"required": false,
"description": "Output file path"
}
]
},
{
"handler": "core",
"availability": [
"core"
],
"implemented": true,
"name": "init",
"description": "Initialize project description file.",
"summary": "Initialize project description file.",
"usage": "/init",
"category": "project"
},
{
"handler": "core",
"availability": [
"core"
],
"implemented": true,
"name": "memory",
"description": "List or edit OpenSpace memory files.",
"summary": "List or edit memory files.",
"usage": "/memory [list|edit|read|logs]",
"category": "project",
"args": [
{
"name": "action",
"required": false,
"description": "list, edit, read, or logs"
}
]
},
{
"handler": "core",
"availability": [
"core"
],
"implemented": true,
"name": "dream",
"description": "Manually consolidate auto-memory.",
"summary": "Manually consolidate memory.",
"usage": "/dream [--logs] [context]",
"category": "project",
"args": [
{
"name": "context",
"required": false,
"description": "Optional dream context or --logs"
}
]
}
] as const;
const CATEGORY_LABELS: Record<SlashCommandCategory, string> = {
"session": "Session",
"navigation": "Navigation",
"tools": "Tools & Models",
"display": "Display & Settings",
"project": "Project"
};
// </openspace-slash-commands:generated>
const SLASH_COMMAND_BY_NAME = new Map<SlashCommandName, SlashCommandDefinition>(
SLASH_COMMAND_REGISTRY.map(
definition => [definition.name, definition] as const,
),
);
const ALIAS_TO_NAME = new Map<string, SlashCommandName>();
for (const def of SLASH_COMMAND_REGISTRY) {
if (def.aliases) {
for (const alias of def.aliases) {
ALIAS_TO_NAME.set(alias, def.name);
}
}
}
export function resolveAlias(input: string): string {
return ALIAS_TO_NAME.get(input) ?? input;
}
export function isSlashCommandName(
command: string,
): command is SlashCommandName {
return SLASH_COMMAND_BY_NAME.has(command as SlashCommandName);
}
export function getSlashCommandDefinition(
command: string,
): SlashCommandDefinition | null {
const resolved = resolveAlias(command);
return isSlashCommandName(resolved)
? SLASH_COMMAND_BY_NAME.get(resolved) ?? null
: null;
}
export function getSlashCommandSummaries(): readonly SlashCommandDefinition[] {
return SLASH_COMMAND_REGISTRY.filter(
command => !command.isHidden && isCommandEnabled(command),
);
}
export function getCommandsByCategory(
category: SlashCommandCategory,
): SlashCommandDefinition[] {
return SLASH_COMMAND_REGISTRY.filter(
command =>
command.category === category &&
!command.isHidden &&
isCommandEnabled(command),
);
}
export function getCommandCompletions(
partial: string,
): SlashCommandDefinition[] {
const lower = partial.toLowerCase();
const seen = new Set<SlashCommandName>();
const results: SlashCommandDefinition[] = [];
for (const def of getSlashCommandSummaries()) {
if (getCommandName(def).startsWith(lower)) {
seen.add(def.name);
results.push(def);
continue;
}
if (def.aliases?.some(alias => alias.startsWith(lower))) {
if (!seen.has(def.name)) {
seen.add(def.name);
results.push(def);
}
}
}
return results;
}
export function getLocalSlashCommands(): SlashCommandDefinition[] {
return getSlashCommandSummaries().filter(command => command.handler === "local");
}
export function getCoreSlashCommands(): SlashCommandDefinition[] {
return getSlashCommandSummaries().filter(command => command.handler === "core");
}
export function parseSlashCommandInput(raw: string): ParsedSlashCommand | null {
const trimmed = raw.trim();
if (!trimmed.startsWith("/")) {
return null;
}
const parts = trimmed.slice(1).trim().split(/\s+/).filter(Boolean);
const [rawCommand = "", ...args] = parts;
const command = resolveAlias(rawCommand);
const definition = getSlashCommandDefinition(command);
return {
raw: trimmed,
command,
args,
definition,
isSupported: definition !== null,
};
}
export function formatSlashCommandHelpText(): string {
const categories: SlashCommandCategory[] = [
"session",
"navigation",
"tools",
"display",
"project",
];
const lines: string[] = [];
for (const category of categories) {
const commands = getCommandsByCategory(category);
if (commands.length === 0) {
continue;
}
lines.push(`\n ${CATEGORY_LABELS[category]}`);
for (const command of commands) {
const aliasStr =
command.aliases && command.aliases.length > 0
? ` (${command.aliases.map(alias => `/${alias}`).join(", ")})`
: "";
const implementation =
command.implemented === false ? " [planned]" : "";
lines.push(
` /${command.name}${aliasStr}${implementation}${command.summary}`,
);
}
}
return `Available commands:${lines.join("\n")}`;
}
export function formatSlashCommandDetailText(
command: SlashCommandDefinition,
): string {
const lines = [
command.usage,
command.description || command.summary,
];
if (command.aliases && command.aliases.length > 0) {
lines.push(`Aliases: ${command.aliases.map(alias => `/${alias}`).join(", ")}`);
}
if (command.args && command.args.length > 0) {
lines.push("Arguments:");
for (const arg of command.args) {
const marker = arg.required ? "required" : "optional";
lines.push(` ${arg.name} (${marker}) — ${arg.description}`);
}
}
lines.push(`Handler: ${command.handler}`);
return lines.join("\n");
}
export function formatSlashCommandStatus(parsed: ParsedSlashCommand): string {
const commandText = parsed.command ? `/${parsed.command}` : "/";
const argsText = parsed.args.length ? ` ${parsed.args.join(" ")}` : "";
return `Sent ${commandText}${argsText}`;
}

View file

@ -0,0 +1,216 @@
import React from "react";
import { Box, Text } from "ink";
import { getColor } from "./design-system/theme.js";
type EventRecord = Record<string, unknown> | null | undefined;
type Props = {
events: EventRecord[];
title?: string;
emptyLabel?: string;
maxEvents?: number;
selectedEventIndex?: number | null;
actionHints?: string[];
};
function getString(record: EventRecord, keys: string[]): string | undefined {
if (!record || typeof record !== "object") {
return undefined;
}
for (const key of keys) {
const value = record[key];
if (typeof value === "string" && value.trim().length > 0) {
return value.trim();
}
}
return undefined;
}
function getNumber(record: EventRecord, keys: string[]): number | undefined {
if (!record || typeof record !== "object") {
return undefined;
}
for (const key of keys) {
const value = record[key];
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (typeof value === "string") {
const parsed = Number(value);
if (Number.isFinite(parsed)) {
return parsed;
}
}
}
return undefined;
}
function truncate(text: string, max: number): string {
if (text.length <= max) {
return text;
}
return `${text.slice(0, Math.max(0, max - 1))}`;
}
function formatTime(timestamp: number | undefined): string {
if (timestamp === undefined) {
return "--:--:--";
}
try {
return new Intl.DateTimeFormat(undefined, {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
}).format(new Date(timestamp));
} catch {
return "--:--:--";
}
}
function agentLabel(record: EventRecord): string {
return (
getString(record, ["agent_id", "agentId", "agent", "id"]) ?? "agent"
);
}
function eventLabel(record: EventRecord): string {
return getString(record, ["event", "type", "name"]) ?? "event";
}
function eventTone(label: string): string {
const normalized = label.toLowerCase();
if (normalized.includes("error") || normalized.includes("fail")) {
return getColor("error");
}
if (normalized.includes("complete") || normalized.includes("ready")) {
return getColor("success");
}
if (normalized.includes("start") || normalized.includes("update")) {
return getColor("primary");
}
if (normalized.includes("transcript")) {
return getColor("accent");
}
return getColor("text");
}
function summarizePayload(record: EventRecord): string | undefined {
if (!record || typeof record !== "object") {
return undefined;
}
const payload = record["payload"];
if (payload === undefined || payload === null) {
return undefined;
}
if (typeof payload === "string") {
return truncate(payload, 96);
}
if (Array.isArray(payload)) {
return truncate(JSON.stringify(payload), 96);
}
if (typeof payload === "object") {
const entries = Object.entries(payload as Record<string, unknown>);
const summary = entries
.slice(0, 3)
.map(([key, value]) => {
if (typeof value === "string") {
return `${key}=${truncate(value, 24)}`;
}
if (typeof value === "number" || typeof value === "boolean") {
return `${key}=${String(value)}`;
}
return key;
})
.join(", ");
return summary.length > 0 ? summary : truncate(JSON.stringify(payload), 96);
}
return String(payload);
}
export function AgentEventFeed({
events,
title = "Recent Agent Events",
emptyLabel = "No agent events yet",
maxEvents = 10,
selectedEventIndex = null,
actionHints = [],
}: Props): React.ReactElement {
const visibleEvents = events
.filter((event): event is Record<string, unknown> => Boolean(event))
.slice(-maxEvents);
const offset = Math.max(0, events.length - visibleEvents.length);
if (visibleEvents.length === 0) {
return (
<Box borderStyle="round" borderColor={getColor("border")} paddingX={1}>
<Text color={getColor("textDim")}>{emptyLabel}</Text>
</Box>
);
}
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor={getColor("border")}
paddingX={1}
>
<Text bold color={getColor("primary")}>
{title} ({events.length})
</Text>
{actionHints.length > 0 ? (
<Text color={getColor("textDim")}>
{actionHints.join(" | ")}
</Text>
) : null}
{visibleEvents.map((record, index) => {
const absoluteIndex = offset + index;
const selected = selectedEventIndex === absoluteIndex;
const time = formatTime(getNumber(record, ["timestamp", "updated_at"]));
const agent = agentLabel(record);
const label = eventLabel(record);
const payload = summarizePayload(record);
return (
<Box key={`${agent}:${label}:${index}`} flexDirection="column" marginTop={1}>
<Box>
<Text color={selected ? getColor("primary") : getColor("textDim")}>
{selected ? "" : " "}
</Text>
<Text color={getColor("textDim")}> </Text>
<Text color={getColor("textDim")}>{time}</Text>
<Text color={getColor("textDim")}> </Text>
<Text bold color={getColor("secondary")}>
{agent}
</Text>
<Text color={getColor("textDim")}> · </Text>
<Text color={selected ? getColor("primary") : eventTone(label)}>
{truncate(label, 32)}
</Text>
</Box>
{payload ? (
<Text color={selected ? getColor("text") : getColor("textDim")}>
{payload}
</Text>
) : null}
</Box>
);
})}
</Box>
);
}

View file

@ -0,0 +1,228 @@
import React from "react";
import { Box, Text } from "ink";
import { getColor } from "./design-system/theme.js";
type AgentRecord = Record<string, unknown> | null | undefined;
type Props = {
agents: AgentRecord[];
title?: string;
emptyLabel?: string;
selectedAgentId?: string | null;
maxAgents?: number;
};
type StatusTone = {
icon: string;
color: string;
label: string;
};
function getString(record: AgentRecord, keys: string[]): string | undefined {
if (!record || typeof record !== "object") {
return undefined;
}
for (const key of keys) {
const value = record[key];
if (typeof value === "string" && value.trim().length > 0) {
return value.trim();
}
}
return undefined;
}
function getNumber(record: AgentRecord, keys: string[]): number | undefined {
if (!record || typeof record !== "object") {
return undefined;
}
for (const key of keys) {
const value = record[key];
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (typeof value === "string") {
const parsed = Number(value);
if (Number.isFinite(parsed)) {
return parsed;
}
}
}
return undefined;
}
function truncate(text: string, max: number): string {
if (text.length <= max) {
return text;
}
return `${text.slice(0, Math.max(0, max - 1))}`;
}
function formatRelativeTime(timestamp: number | undefined): string {
if (timestamp === undefined) {
return "";
}
const diffSeconds = Math.max(0, Math.floor((Date.now() - timestamp) / 1000));
if (diffSeconds < 60) return `${diffSeconds}s ago`;
if (diffSeconds < 3600) return `${Math.floor(diffSeconds / 60)}m ago`;
if (diffSeconds < 86400) return `${Math.floor(diffSeconds / 3600)}h ago`;
return `${Math.floor(diffSeconds / 86400)}d ago`;
}
function statusTone(record: AgentRecord): StatusTone {
const rawStatus = (getString(record, [
"status",
"state",
"phase",
"health",
]) ?? "unknown").toLowerCase();
switch (rawStatus) {
case "running":
case "active":
case "connected":
case "ready":
return { icon: "●", color: getColor("success"), label: rawStatus };
case "waiting":
case "pending":
case "paused":
return { icon: "◐", color: getColor("warning"), label: rawStatus };
case "error":
case "failed":
return { icon: "✗", color: getColor("error"), label: rawStatus };
case "idle":
case "stopped":
case "disconnected":
return { icon: "○", color: getColor("muted"), label: rawStatus };
default:
return { icon: "•", color: getColor("textDim"), label: rawStatus };
}
}
function buildSummary(record: AgentRecord): string {
const parts: string[] = [];
const type = getString(record, ["type", "agent_type"]);
const mode = getString(record, ["mode"]);
const model = getString(record, ["model"]);
const sessionId = getString(record, ["session_id", "sessionId"]);
const taskId = getString(record, ["task_id", "taskId"]);
const updatedAt = formatRelativeTime(
getNumber(record, ["updated_at", "updatedAt"]),
);
if (type) parts.push(type);
if (mode) parts.push(mode);
if (model) parts.push(model);
if (sessionId) parts.push(`session ${truncate(sessionId, 18)}`);
if (taskId) parts.push(`task ${truncate(taskId, 18)}`);
if (updatedAt) parts.push(updatedAt);
const summary = getString(record, ["summary", "preview", "description"]);
if (summary) {
parts.push(truncate(summary, 72));
}
return parts.join(" · ");
}
function agentLabel(record: AgentRecord): string {
return (
getString(record, [
"name",
"title",
"label",
"agent_name",
"agent_id",
"id",
]) ?? "Unnamed agent"
);
}
function agentId(record: AgentRecord): string | undefined {
return getString(record, ["agent_id", "agentId", "id"]);
}
function agentError(record: AgentRecord): string | undefined {
return getString(record, ["error", "message", "last_error"]);
}
export function AgentListPanel({
agents,
title = "Agents",
emptyLabel = "No agents reported yet",
selectedAgentId,
maxAgents = 8,
}: Props): React.ReactElement {
const visibleAgents = agents
.filter((agent): agent is Record<string, unknown> => Boolean(agent))
.slice(-maxAgents);
const hiddenCount = Math.max(0, agents.length - visibleAgents.length);
if (visibleAgents.length === 0) {
return (
<Box borderStyle="round" borderColor={getColor("border")} paddingX={1}>
<Text color={getColor("textDim")}>{emptyLabel}</Text>
</Box>
);
}
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor={getColor("border")}
paddingX={1}
>
<Text bold color={getColor("primary")}>
{title} ({agents.length})
</Text>
{hiddenCount > 0 ? (
<Text color={getColor("textDim")}>
Showing the latest {visibleAgents.length} agent{visibleAgents.length === 1 ? "" : "s"}.
</Text>
) : null}
{visibleAgents.map(record => {
const id = agentId(record) ?? agentLabel(record);
const selected = selectedAgentId !== null && selectedAgentId === id;
const tone = statusTone(record);
const summary = buildSummary(record);
const error = agentError(record);
return (
<Box key={id} flexDirection="column" marginTop={1}>
<Box>
{selected ? (
<Text color={tone.color} bold>
{" "}
</Text>
) : null}
<Text color={tone.color}>
{tone.icon}{" "}
</Text>
<Text bold>{agentLabel(record)}</Text>
<Text color={getColor("textDim")}>
{" "} {tone.label}
</Text>
</Box>
{summary ? (
<Text color={getColor("textDim")}> {summary}</Text>
) : null}
{error ? (
<Text color={getColor("error")}> Error: {truncate(error, 96)}</Text>
) : null}
</Box>
);
})}
</Box>
);
}

View file

@ -0,0 +1,217 @@
import React from "react";
import { Box, Text } from "ink";
import type { AppMessage } from "../state/AppStateStore.js";
import { getColor } from "./design-system/theme.js";
import { AgentEventFeed } from "./AgentEventFeed.js";
import { AgentListPanel } from "./AgentListPanel.js";
import {
AgentTranscriptPanel,
type AgentTranscriptHandle,
} from "./AgentTranscriptPanel.js";
import { AgentTranscriptPreview } from "./AgentTranscriptPreview.js";
import { BackgroundSessionSummary } from "./BackgroundSessionSummary.js";
export type RuntimeTab = "list" | "events" | "transcript";
type Props = {
title?: string;
selectedTab: RuntimeTab;
agents: Array<Record<string, unknown>>;
events: Array<Record<string, unknown>>;
transcriptMessages: AppMessage[];
backgroundSession?: Record<string, unknown> | null;
selectedAgentId?: string | null;
selectedEventIndex?: number | null;
transcriptCursor?: number | null;
selectedMessageId?: string | null;
agentLabel?: string;
listTitle?: string;
eventTitle?: string;
transcriptTitle?: string;
transcriptEmptyLabel?: string;
actionHints?: string[];
maxAgents?: number;
maxEvents?: number;
maxTranscriptMessages?: number;
transcriptPanelRef?: React.Ref<AgentTranscriptHandle | null>;
transcriptSearchVisible?: boolean;
transcriptSearchQuery?: string;
transcriptSearchMatchCount?: number;
transcriptSearchCurrentMatch?: number;
onTranscriptSearchMatchesChange?: (count: number, current: number) => void;
onTranscriptCursorChange?: (messageId: string | null, index: number | null) => void;
};
const TAB_LABELS: Record<RuntimeTab, string> = {
list: "Agents",
events: "Events",
transcript: "Transcript",
};
function tabColor(tab: RuntimeTab, selectedTab: RuntimeTab): string {
return tab === selectedTab ? getColor("primary") : getColor("textDim");
}
function tabWeight(tab: RuntimeTab, selectedTab: RuntimeTab): boolean {
return tab === selectedTab;
}
function selectionStatus(props: {
selectedTab: RuntimeTab;
selectedAgentId?: string | null;
selectedEventIndex?: number | null;
transcriptCursor?: number | null;
selectedMessageId?: string | null;
}): string {
switch (props.selectedTab) {
case "list":
return props.selectedAgentId
? `Selected agent: ${props.selectedAgentId}`
: "Selected agent: none";
case "events":
return props.selectedEventIndex !== null &&
props.selectedEventIndex !== undefined
? `Selected event: ${props.selectedEventIndex + 1}`
: "Selected event: none";
case "transcript":
if (props.selectedMessageId) {
return `Selected message: ${props.selectedMessageId}`;
}
return props.transcriptCursor !== null &&
props.transcriptCursor !== undefined
? `Transcript cursor: ${props.transcriptCursor + 1}`
: "Transcript cursor: none";
default:
return "Selection: none";
}
}
function renderTabStrip(selectedTab: RuntimeTab): React.ReactElement {
return (
<Box>
{(Object.keys(TAB_LABELS) as RuntimeTab[]).map(tab => (
<Text
key={tab}
color={tabColor(tab, selectedTab)}
bold={tabWeight(tab, selectedTab)}
>
{tab === selectedTab ? "" : " "} {TAB_LABELS[tab]}
{" "}
</Text>
))}
</Box>
);
}
export function AgentRuntimePane({
title = "Runtime",
selectedTab,
agents,
events,
transcriptMessages,
backgroundSession = null,
selectedAgentId = null,
selectedEventIndex = null,
transcriptCursor = null,
selectedMessageId = null,
agentLabel = "Viewed agent",
listTitle,
eventTitle,
transcriptTitle,
transcriptEmptyLabel,
actionHints = [],
maxAgents = 8,
maxEvents = 10,
maxTranscriptMessages = 5,
transcriptPanelRef,
transcriptSearchVisible = false,
transcriptSearchQuery = "",
transcriptSearchMatchCount = 0,
transcriptSearchCurrentMatch = 0,
onTranscriptSearchMatchesChange,
onTranscriptCursorChange,
}: Props): React.ReactElement {
const activePanel =
selectedTab === "list" ? (
<AgentListPanel
agents={agents}
title={listTitle}
selectedAgentId={selectedAgentId}
maxAgents={maxAgents}
/>
) : selectedTab === "events" ? (
<AgentEventFeed
events={events}
title={eventTitle}
maxEvents={maxEvents}
selectedEventIndex={selectedEventIndex}
actionHints={actionHints}
/>
) : (
<AgentTranscriptPanel
ref={transcriptPanelRef}
messages={transcriptMessages}
agentLabel={agentLabel}
title={transcriptTitle}
emptyLabel={transcriptEmptyLabel}
cursor={transcriptCursor}
selectedMessageId={selectedMessageId}
actionHints={actionHints}
searchVisible={transcriptSearchVisible}
searchQuery={transcriptSearchQuery}
searchMatchCount={transcriptSearchMatchCount}
searchCurrentMatch={transcriptSearchCurrentMatch}
onSearchMatchesChange={onTranscriptSearchMatchesChange}
onCursorChange={onTranscriptCursorChange}
/>
);
const transcriptPreview =
selectedTab !== "transcript" && transcriptMessages.length > 0 ? (
<Box marginTop={1}>
<AgentTranscriptPreview
messages={transcriptMessages}
agentLabel={agentLabel}
maxMessages={Math.min(3, maxTranscriptMessages)}
/>
</Box>
) : null;
return (
<Box flexDirection="column">
<Text bold color={getColor("primary")}>
{title}
</Text>
<Box marginTop={1}>{renderTabStrip(selectedTab)}</Box>
<Text color={getColor("textDim")}>
{selectionStatus({
selectedTab,
selectedAgentId,
selectedEventIndex,
transcriptCursor,
selectedMessageId,
})}
</Text>
{backgroundSession ? (
<Box marginTop={1}>
<BackgroundSessionSummary session={backgroundSession} />
</Box>
) : null}
<Box marginTop={1}>{activePanel}</Box>
{transcriptPreview}
{actionHints.length > 0 ? (
<Box marginTop={1}>
<Text color={getColor("textDim")}>
{actionHints.join(" | ")}
</Text>
</Box>
) : null}
</Box>
);
}

View file

@ -0,0 +1,228 @@
import React from "react";
import { Box, Text } from "ink";
import type { JumpHandle } from "./VirtualMessageList.js";
import type { MessageActionsNav, MessageActionsState } from "./messageActions.js";
import type { AppMessage } from "../state/AppStateStore.js";
import { estimateMessagesContentHeight, Messages } from "./Messages.js";
import ScrollBox, { type ScrollBoxHandle } from "../ink/components/ScrollBox.js";
import { useTerminalSize } from "../hooks/useTerminalSize.js";
import { getColor } from "./design-system/theme.js";
import { TranscriptSearchBar } from "./transcript/TranscriptSearchBar.js";
export type AgentTranscriptHandle = JumpHandle & {
enterCursor: () => void;
navigatePrev: () => void;
navigateNext: () => void;
navigateTop: () => void;
navigateBottom: () => void;
scrollToBottom: () => void;
};
type Props = {
messages: AppMessage[];
agentLabel?: string;
title?: string;
emptyLabel?: string;
maxRows?: number;
selectedMessageId?: string | null;
cursor?: number | null;
actionHints?: string[];
searchVisible?: boolean;
searchQuery?: string;
searchMatchCount?: number;
searchCurrentMatch?: number;
onSearchMatchesChange?: (count: number, current: number) => void;
onCursorChange?: (messageId: string | null, index: number | null) => void;
};
function normalizeIndex(
index: number | null | undefined,
length: number,
): number | null {
if (length === 0 || index === null || index === undefined || Number.isNaN(index)) {
return null;
}
return Math.max(0, Math.min(length - 1, index));
}
export const AgentTranscriptPanel = React.forwardRef<
AgentTranscriptHandle | null,
Props
>(function AgentTranscriptPanel(
{
messages,
agentLabel = "Viewed agent",
title = "Agent Transcript",
emptyLabel = "No transcript available",
maxRows,
selectedMessageId = null,
cursor = null,
actionHints = [],
searchVisible = false,
searchQuery = "",
searchMatchCount = 0,
searchCurrentMatch = 0,
onSearchMatchesChange,
onCursorChange,
}: Props,
ref,
): React.ReactElement {
const terminalSize = useTerminalSize();
const scrollRef = React.useRef<ScrollBoxHandle | null>(null);
const jumpRef = React.useRef<JumpHandle | null>(null);
const cursorNavRef = React.useRef<MessageActionsNav | null>(null);
const lastSyncedSelectionRef = React.useRef<string | null>(null);
const maxScrollableRows =
maxRows ?? Math.max(8, Math.min(18, Math.floor(terminalSize.rows * 0.3)));
const contentHeight = estimateMessagesContentHeight(
messages,
Math.max(24, terminalSize.columns - 12),
);
const handleCursorChange = React.useCallback(
(cursorState: MessageActionsState | null): void => {
const messageId = cursorState?.id ?? null;
if (messageId === null) {
lastSyncedSelectionRef.current = null;
onCursorChange?.(null, null);
return;
}
const index = messages.findIndex(message => message.id === messageId);
lastSyncedSelectionRef.current = messageId;
onCursorChange?.(messageId, index >= 0 ? index : null);
},
[messages, onCursorChange],
);
React.useEffect(() => {
if (!jumpRef.current) {
return;
}
if (selectedMessageId) {
if (lastSyncedSelectionRef.current === selectedMessageId) {
return;
}
const index = messages.findIndex(message => message.id === selectedMessageId);
if (index >= 0) {
lastSyncedSelectionRef.current = selectedMessageId;
jumpRef.current.jumpToIndex(index);
}
return;
}
const normalizedCursor = normalizeIndex(cursor, messages.length);
if (normalizedCursor !== null) {
const messageId = messages[normalizedCursor]?.id ?? null;
if (messageId !== null && lastSyncedSelectionRef.current !== messageId) {
lastSyncedSelectionRef.current = messageId;
jumpRef.current.jumpToIndex(normalizedCursor);
}
}
}, [cursor, messages, selectedMessageId]);
React.useImperativeHandle(
ref,
() => ({
jumpToIndex(index: number) {
jumpRef.current?.jumpToIndex(index);
},
setSearchQuery(query: string) {
jumpRef.current?.setSearchQuery(query);
},
nextMatch() {
jumpRef.current?.nextMatch();
},
prevMatch() {
jumpRef.current?.prevMatch();
},
setAnchor() {
jumpRef.current?.setAnchor();
},
warmSearchIndex() {
return jumpRef.current?.warmSearchIndex() ?? Promise.resolve(0);
},
disarmSearch() {
jumpRef.current?.disarmSearch();
},
enterCursor() {
cursorNavRef.current?.enterCursor();
},
navigatePrev() {
cursorNavRef.current?.navigatePrev();
},
navigateNext() {
cursorNavRef.current?.navigateNext();
},
navigateTop() {
cursorNavRef.current?.navigateTop();
},
navigateBottom() {
cursorNavRef.current?.navigateBottom();
},
scrollToBottom() {
scrollRef.current?.scrollToBottom();
},
}),
[],
);
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor={getColor("border")}
paddingX={1}
>
<Text bold color={getColor("primary")}>
{title} ({messages.length})
</Text>
<Text color={getColor("textDim")}>
{agentLabel}
</Text>
{actionHints.length > 0 ? (
<Text color={getColor("textDim")}>
{actionHints.join(" | ")}
</Text>
) : null}
{searchVisible ? (
<TranscriptSearchBar
query={searchQuery}
matchCount={searchMatchCount}
currentMatch={searchCurrentMatch}
/>
) : null}
<Box marginTop={1}>
{/*
Transitional: this embeds the existing primary transcript message surface
inside a nested ScrollBox so the agent pane can reuse `Messages`,
`VirtualMessageList`, `messageActions`, and jump/search infrastructure
before the app moves to a single shared multi-pane scroll coordinator.
*/}
<ScrollBox
ref={scrollRef}
height={maxScrollableRows}
contentHeight={Math.max(maxScrollableRows, contentHeight)}
borderStyle="round"
borderColor="gray"
paddingX={1}
>
<Messages
messages={messages}
maxRows={maxScrollableRows}
scrollRef={scrollRef}
jumpRef={jumpRef}
cursorNavRef={cursorNavRef}
onCursorChange={handleCursorChange}
onSearchMatchesChange={onSearchMatchesChange}
trackStickyPrompt={false}
emptyLabel={emptyLabel}
/>
</ScrollBox>
</Box>
</Box>
);
});

View file

@ -0,0 +1,100 @@
import React from "react";
import { Box, Text } from "ink";
import type { AppMessage } from "../state/AppStateStore.js";
import { getMessageText } from "../screens/shared.js";
import {
MESSAGE_ROLE_LABELS,
MESSAGE_ROLE_TOKENS,
} from "./Message.js";
import { MessageTimestamp } from "./MessageTimestamp.js";
import { getColor } from "./design-system/theme.js";
type Props = {
messages: AppMessage[];
agentLabel?: string;
title?: string;
emptyLabel?: string;
maxMessages?: number;
};
function isRenderableMessage(message: AppMessage): boolean {
if (message.meta?.hidden === true) {
return false;
}
if (message.meta?.budget === true) {
return false;
}
return true;
}
function truncate(text: string, max: number): string {
if (text.length <= max) {
return text;
}
return `${text.slice(0, Math.max(0, max - 1))}`;
}
function summarizeMessage(message: AppMessage): string {
const text = getMessageText(message).replace(/\s+/g, " ").trim();
return truncate(text || " ", 100);
}
export function AgentTranscriptPreview({
messages,
agentLabel = "Viewed agent",
title = "Agent Transcript",
emptyLabel = "No transcript available",
maxMessages = 5,
}: Props): React.ReactElement {
const visibleMessages = messages.filter(isRenderableMessage).slice(-maxMessages);
if (visibleMessages.length === 0) {
return (
<Box borderStyle="round" borderColor={getColor("border")} paddingX={1}>
<Text color={getColor("textDim")}>{emptyLabel}</Text>
</Box>
);
}
const latestMessage = visibleMessages[visibleMessages.length - 1]!;
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor={getColor("border")}
paddingX={1}
>
<Text bold color={getColor("primary")}>
{title} ({messages.length})
</Text>
<Text color={getColor("textDim")}>
{agentLabel} · {visibleMessages.length} message{visibleMessages.length === 1 ? "" : "s"}
</Text>
<Box>
<MessageTimestamp message={latestMessage} />
<Text color={getColor("textDim")}> </Text>
<Text color={getColor(MESSAGE_ROLE_TOKENS[latestMessage.role])} bold>
[{MESSAGE_ROLE_LABELS[latestMessage.role]}]
</Text>
<Text color={getColor("textDim")}> </Text>
<Text color={getColor("text")}>{summarizeMessage(latestMessage)}</Text>
</Box>
{visibleMessages.slice(0, -1).map(message => (
<Box key={message.id} marginTop={1}>
<Text color={getColor("textDim")}>
{MESSAGE_ROLE_LABELS[message.role]}:
</Text>
<Text color={getColor("textDim")}> </Text>
<Text color={getColor(MESSAGE_ROLE_TOKENS[message.role])}>
{summarizeMessage(message)}
</Text>
</Box>
))}
</Box>
);
}

View file

@ -0,0 +1,97 @@
import React from "react";
import type { StructuredIO } from "../bridge/structuredIO.js";
import { FpsMetricsProvider } from "../context/fpsMetrics.js";
import { StatsProvider } from "../context/stats.js";
import instances from "../ink/instances.js";
import { KeybindingSetup } from "../keybindings/KeybindingProviderSetup.js";
import {
type AppState,
type AppStateStore,
type ScreenName,
} from "../state/AppStateStore.js";
import { AppStateProvider } from "../state/AppState.js";
import { FpsTracker } from "../utils/fpsTracker.js";
import { Doctor } from "../screens/Doctor.js";
import { REPL } from "../screens/REPL.js";
import { ResumeConversation } from "../screens/ResumeConversation.js";
type Props = {
screen: ScreenName;
io: StructuredIO | null;
store: AppStateStore;
initialState?: AppState;
stdout: NodeJS.WriteStream;
};
export function App({
screen,
io,
store,
initialState,
stdout,
}: Props): React.ReactElement {
const fpsTrackerRef = React.useRef<FpsTracker | null>(null);
const [, setRenderEpoch] = React.useState(0);
if (fpsTrackerRef.current === null) {
fpsTrackerRef.current = new FpsTracker();
}
React.useEffect(() => {
const controls = {
invalidatePrevFrame: () => {
setRenderEpoch(epoch => epoch + 1);
},
forceRedraw: () => {
setRenderEpoch(epoch => epoch + 1);
},
};
instances.set(stdout, controls);
instances.set(process.stdout, controls);
instances.set(process.stderr, controls);
return () => {
instances.delete(stdout);
instances.delete(process.stdout);
instances.delete(process.stderr);
};
}, [stdout]);
const content = (() => {
switch (screen) {
case "doctor":
return <Doctor io={io} />;
case "resume":
return <ResumeConversation io={io} />;
case "repl":
default:
return (
<KeybindingSetup>
<REPL io={io} />
</KeybindingSetup>
);
}
})();
return (
<StatsProvider>
<AppStateProvider
store={store}
initialState={initialState}
>
{/*
Transitional: OpenSpace scopes prompt overlays inside the fullscreen
message surface. OpenSpace now mounts that provider there instead of at
the app root, but still relies on upstream Ink for frame tracking.
*/}
<FpsMetricsProvider
getFpsMetrics={() => fpsTrackerRef.current?.getMetrics()}
recordFrame={durationMs => fpsTrackerRef.current?.record(durationMs)}
>
{content}
</FpsMetricsProvider>
</AppStateProvider>
</StatsProvider>
);
}

View file

@ -0,0 +1,79 @@
import React from "react";
import { Box, Text } from "ink";
import { getColor } from "./design-system/theme.js";
import { BackgroundSessionSummary } from "./BackgroundSessionSummary.js";
import type { RuntimeTab } from "./AgentRuntimePane.js";
type ControlHint = {
key: string;
label: string;
description?: string;
};
type Props = {
session: Record<string, unknown> | null;
title?: string;
emptyLabel?: string;
selectedTab?: RuntimeTab | null;
actionHints?: string[];
controls?: ControlHint[];
};
function tabLabel(tab: RuntimeTab | null | undefined): string {
switch (tab) {
case "list":
return "agents";
case "events":
return "events";
case "transcript":
return "transcript";
default:
return "idle";
}
}
export function BackgroundControlsPanel({
session,
title = "Background Controls",
emptyLabel = "No background session active",
selectedTab = null,
actionHints = [],
controls = [],
}: Props): React.ReactElement {
return (
<Box flexDirection="column">
<Text bold color={getColor("primary")}>
{title}
</Text>
<Box marginTop={1}>
<BackgroundSessionSummary
session={session}
title="Session"
emptyLabel={emptyLabel}
/>
</Box>
<Text color={getColor("textDim")}>
Active pane: {tabLabel(selectedTab)}
</Text>
{actionHints.length > 0 ? (
<Text color={getColor("textDim")}>
{actionHints.join(" | ")}
</Text>
) : null}
{controls.length > 0 ? (
<Box flexDirection="column" marginTop={1}>
{controls.map(control => (
<Text key={control.key} color={getColor("textDim")}>
[{control.key}] {control.label}
{control.description ? ` - ${control.description}` : ""}
</Text>
))}
</Box>
) : null}
</Box>
);
}

View file

@ -0,0 +1,170 @@
import React from "react";
import { Box, Text } from "ink";
import { getColor } from "./design-system/theme.js";
type BackgroundSessionLike = Record<string, unknown> | null | undefined;
type BackgroundSessionFields = {
sessionId?: string;
session_id?: string;
taskId?: string;
task_id?: string;
status?: string;
title?: string;
updatedAt?: number;
updated_at?: number | string;
};
type Props = {
session: BackgroundSessionLike;
title?: string;
emptyLabel?: string;
};
function getString(
record: BackgroundSessionLike,
keys: string[],
): string | undefined {
if (!record || typeof record !== "object") {
return undefined;
}
for (const key of keys) {
const value = (record as BackgroundSessionFields)[key as keyof BackgroundSessionFields];
if (typeof value === "string" && value.trim().length > 0) {
return value.trim();
}
}
return undefined;
}
function getNumber(
record: BackgroundSessionLike,
keys: string[],
): number | undefined {
if (!record || typeof record !== "object") {
return undefined;
}
for (const key of keys) {
const value = (record as BackgroundSessionFields)[key as keyof BackgroundSessionFields];
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (typeof value === "string") {
const parsed = Number(value);
if (Number.isFinite(parsed)) {
return parsed;
}
}
}
return undefined;
}
function truncate(text: string, max: number): string {
if (text.length <= max) {
return text;
}
return `${text.slice(0, Math.max(0, max - 1))}`;
}
function formatRelativeTime(timestamp: number | undefined): string {
if (timestamp === undefined) {
return "";
}
const diffSeconds = Math.max(0, Math.floor((Date.now() - timestamp) / 1000));
if (diffSeconds < 60) return `${diffSeconds}s ago`;
if (diffSeconds < 3600) return `${Math.floor(diffSeconds / 60)}m ago`;
if (diffSeconds < 86400) return `${Math.floor(diffSeconds / 3600)}h ago`;
return `${Math.floor(diffSeconds / 86400)}d ago`;
}
function statusTone(status: string | undefined): {
icon: string;
color: string;
label: string;
} {
const normalized = (status ?? "unknown").toLowerCase();
switch (normalized) {
case "running":
case "active":
case "open":
case "focused":
return { icon: "●", color: getColor("success"), label: normalized };
case "queued":
case "waiting":
case "pending":
return { icon: "◐", color: getColor("warning"), label: normalized };
case "stopped":
case "idle":
case "closed":
return { icon: "○", color: getColor("muted"), label: normalized };
case "error":
case "failed":
return { icon: "✗", color: getColor("error"), label: normalized };
default:
return { icon: "•", color: getColor("textDim"), label: normalized };
}
}
export function BackgroundSessionSummary({
session,
title = "Background Session",
emptyLabel = "No background session active",
}: Props): React.ReactElement {
if (!session) {
return (
<Box borderStyle="round" borderColor={getColor("border")} paddingX={1}>
<Text color={getColor("textDim")}>{emptyLabel}</Text>
</Box>
);
}
const sessionId = getString(session, ["sessionId", "session_id"]);
const taskId = getString(session, ["taskId", "task_id"]);
const status = statusTone(getString(session, ["status"]));
const sessionTitle = getString(session, ["title"]);
const updatedAt = formatRelativeTime(
getNumber(session, ["updatedAt", "updated_at"]),
);
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor={getColor("border")}
paddingX={1}
>
<Text bold color={getColor("primary")}>
{title}
</Text>
<Box marginTop={1}>
<Text color={status.color}>
{status.icon}{" "}
</Text>
<Text bold>{sessionTitle ?? sessionId ?? "Background work"}</Text>
<Text color={getColor("textDim")}>
{" "} {status.label}
</Text>
</Box>
{sessionId ? (
<Text color={getColor("textDim")}>Session: {truncate(sessionId, 40)}</Text>
) : null}
{taskId ? (
<Text color={getColor("textDim")}>Task: {truncate(taskId, 40)}</Text>
) : null}
{updatedAt ? (
<Text color={getColor("textDim")}>Updated {updatedAt}</Text>
) : null}
</Box>
);
}

View file

@ -0,0 +1,57 @@
import React from "react";
import { Text } from "ink";
import type {
BackgroundAgentTaskState,
CoordinatorRuntimeState,
} from "../state/AppStateStore.js";
import { getColor } from "./design-system/theme.js";
type Props = {
coordinator: CoordinatorRuntimeState;
backgroundTasks: Record<string, BackgroundAgentTaskState>;
};
function countRunningTeamTasks(
tasks: Record<string, BackgroundAgentTaskState>,
teamName: string | undefined,
): number {
return Object.values(tasks).filter(task => {
if (teamName && task.teamName !== teamName) {
return false;
}
return ["running", "pending", "starting"].includes(task.status.toLowerCase());
}).length;
}
export function CoordinatorStatusBar({
coordinator,
backgroundTasks,
}: Props): React.ReactElement | null {
const derivedRunning = countRunningTeamTasks(
backgroundTasks,
coordinator.teamName,
);
const runningWorkers = Math.max(
coordinator.runningWorkers,
derivedRunning,
);
const totalWorkers = Math.max(
coordinator.totalWorkers,
Object.values(backgroundTasks).filter(task =>
coordinator.teamName ? task.teamName === coordinator.teamName : Boolean(task.teamName),
).length,
);
if (!coordinator.teamName && runningWorkers === 0 && totalWorkers === 0) {
return null;
}
const teamLabel = coordinator.teamName ?? "default";
const status = coordinator.status ? ` ${coordinator.status}` : "";
return (
<Text color={getColor("accent")}>
Coordinator: {teamLabel}{status} | {runningWorkers}/{totalWorkers} workers running
</Text>
);
}

View file

@ -0,0 +1,238 @@
import React from "react";
import { Box, Text } from "ink";
import { QueuedMessageProvider } from "../context/QueuedMessageContext.js";
import { useRecordFpsFrame } from "../context/fpsMetrics.js";
import { ModalContext } from "../context/modalContext.js";
import {
PromptOverlayProvider,
usePromptOverlay,
usePromptOverlayDialog,
} from "../context/promptOverlayContext.js";
import { useTerminalSize } from "../hooks/useTerminalSize.js";
import ScrollBox, {
type ScrollBoxHandle,
} from "../ink/components/ScrollBox.js";
import { SlashCommandComplete } from "./PromptInput/SlashCommandComplete.js";
import { truncateToDisplayWidth } from "../utils/textWidth.js";
export type StickyPrompt = {
sourceId?: string;
text: string;
scrollTo?: () => void;
};
export const ScrollChromeContext = React.createContext<{
setStickyPrompt: (prompt: StickyPrompt | null) => void;
} | null>(null);
type Props = {
messages: React.ReactNode;
afterMessages?: React.ReactNode;
bottom: React.ReactNode;
overlay?: React.ReactNode;
bottomFloat?: React.ReactNode;
modal?: React.ReactNode;
modalScrollRef?: React.RefObject<ScrollBoxHandle | null>;
scrollRef?: React.RefObject<ScrollBoxHandle | null>;
contentHeight?: number;
scrollRows?: number;
};
function estimatePromptOverlayRows(
suggestionsCount: number,
): number {
if (suggestionsCount === 0) {
return 0;
}
return Math.min(10, suggestionsCount + 2);
}
function truncateStickyPrompt(text: string, columns: number): string {
const safeWidth = Math.max(12, columns - 6);
return truncateToDisplayWidth(text, safeWidth);
}
function FullscreenLayoutBody({
messages,
afterMessages,
bottom,
overlay,
bottomFloat,
modal,
modalScrollRef,
scrollRef,
contentHeight,
scrollRows: scrollRowsProp,
}: Props): React.ReactElement {
const size = useTerminalSize();
const promptOverlay = usePromptOverlay();
const promptOverlayDialog = usePromptOverlayDialog();
const recordFrame = useRecordFpsFrame();
const lastCommitRef = React.useRef<number | null>(null);
const internalModalScrollRef = React.useRef<ScrollBoxHandle | null>(null);
const resolvedModalScrollRef = modalScrollRef ?? internalModalScrollRef;
const [stickyPrompt, setStickyPrompt] = React.useState<StickyPrompt | null>(
null,
);
const updateStickyPrompt = React.useCallback(
(nextPrompt: StickyPrompt | null) => {
setStickyPrompt(current => {
if (current === null && nextPrompt === null) {
return current;
}
if (
current !== null &&
nextPrompt !== null &&
current.sourceId === nextPrompt.sourceId &&
current.text === nextPrompt.text
) {
return current;
}
return nextPrompt;
});
},
[],
);
const scrollChromeValue = React.useMemo(
() => ({ setStickyPrompt: updateStickyPrompt }),
[updateStickyPrompt],
);
React.useLayoutEffect(() => {
const now = performance.now();
if (lastCommitRef.current !== null) {
recordFrame?.(now - lastCommitRef.current);
}
lastCommitRef.current = now;
});
const promptOverlayRows = promptOverlayDialog
? Math.max(6, Math.floor(size.rows * 0.25))
: estimatePromptOverlayRows(promptOverlay?.suggestions.length ?? 0);
const modalRows = modal ? Math.max(8, Math.floor(size.rows * 0.35)) : 0;
const scrollRows = scrollRowsProp ?? Math.max(
6,
size.rows - 8 - promptOverlayRows - modalRows,
);
const maxOverlayRows = Math.max(4, size.rows - 12);
const suggestionCount = promptOverlay?.suggestions.length ?? 0;
const maxOverlayItems = Math.max(
1,
Math.min(
suggestionCount,
suggestionCount > maxOverlayRows
? Math.max(1, maxOverlayRows - 1)
: maxOverlayRows,
),
);
const stickyPromptHeader =
stickyPrompt && !overlay ? (
<Box
flexShrink={0}
marginTop={1}
borderStyle="round"
borderColor="gray"
borderLeft={false}
borderRight={false}
paddingX={1}
width="100%"
>
<Text color="gray">
{truncateStickyPrompt(stickyPrompt.text, size.columns)}
</Text>
</Box>
) : null;
const surfaceContent = (
<ScrollChromeContext.Provider value={scrollChromeValue}>
<Box flexDirection="column">
{messages}
{afterMessages ? <Box flexDirection="column">{afterMessages}</Box> : null}
{overlay ? (
<Box marginTop={1}>
<QueuedMessageProvider isFirst={true}>
{overlay}
</QueuedMessageProvider>
</Box>
) : null}
{bottomFloat ? <Box marginTop={1}>{bottomFloat}</Box> : null}
</Box>
</ScrollChromeContext.Provider>
);
return (
<Box flexDirection="column" flexGrow={1} width="100%">
{stickyPromptHeader}
<ScrollBox
ref={scrollRef}
flexGrow={1}
height={scrollRows}
contentHeight={contentHeight}
stickyScroll={true}
overflowY="hidden"
>
{surfaceContent}
</ScrollBox>
{modal ? (
<Box marginTop={1} flexDirection="column">
<Text color="gray"></Text>
<ModalContext.Provider
value={{
rows: Math.max(3, modalRows - 2),
columns: Math.max(24, size.columns - 4),
scrollRef: resolvedModalScrollRef,
}}
>
{/*
Transitional: OpenSpace paints this in an absolute modal slot over
the fullscreen scroll region. Upstream Ink in OpenSpace still lacks
the same slotting primitives, so this keeps the modal anchored in a
dedicated section while preserving the modal context contract.
*/}
<ScrollBox
ref={resolvedModalScrollRef}
height={modalRows}
borderStyle="round"
borderColor="gray"
paddingX={1}
>
{modal}
</ScrollBox>
</ModalContext.Provider>
</Box>
) : null}
<Box flexDirection="column" flexShrink={0} width="100%" overflowY="hidden">
{promptOverlayDialog ? (
<Box width="100%">
{promptOverlayDialog}
</Box>
) : promptOverlay?.suggestions.length ? (
<Box paddingX={2} width="100%">
<SlashCommandComplete
items={promptOverlay.suggestions}
selectedIndex={promptOverlay.selectedSuggestion}
visible={promptOverlay.suggestions.length > 0}
maxVisibleItems={maxOverlayItems}
maxColumnWidth={promptOverlay.maxColumnWidth}
bordered={false}
/>
</Box>
) : null}
{bottom}
</Box>
</Box>
);
}
export function FullscreenLayout(props: Props): React.ReactElement {
return (
<PromptOverlayProvider>
<FullscreenLayoutBody {...props} />
</PromptOverlayProvider>
);
}

View file

@ -0,0 +1,327 @@
import React from "react";
import { Box, Text } from "ink";
import type {
AppMessage,
AppMessageRole,
} from "../state/AppStateStore.js";
import type { StructuredMessageContentBlock } from "../bridge/protocol.js";
import { getMessageText, stringifyUnknown } from "../screens/shared.js";
import {
getColor,
type ColorToken,
} from "./design-system/theme.js";
import { CollapsibleToolCall } from "./messages/CollapsibleToolCall.js";
import { StreamingText } from "./messages/StreamingText.js";
import { formatStructuredTextForDisplay } from "../utils/structuredDisplay.js";
export const MESSAGE_ROLE_TOKENS: Record<AppMessageRole, ColorToken> = {
system: "systemMessage",
user: "userMessage",
assistant: "assistantMessage",
tool: "toolMessage",
status: "statusMessage",
error: "errorMessage",
};
export const MESSAGE_ROLE_LABELS: Record<AppMessageRole, string> = {
system: "SYS",
user: "YOU",
assistant: "AI",
tool: "TOOL",
status: "INFO",
error: "ERR",
};
type ToolMessageShape = {
toolName: string;
input: string;
result?: string;
error?: string;
status?: "pending" | "running" | "complete" | "error";
progress?: string;
collapsed?: boolean;
};
type Props = {
message: AppMessage;
expanded?: boolean;
};
function isToolUseBlock(
block: StructuredMessageContentBlock,
): block is Extract<StructuredMessageContentBlock, { type: "tool_use" }> {
return block.type === "tool_use";
}
function isFieldBlock(
block: StructuredMessageContentBlock,
): block is Extract<StructuredMessageContentBlock, { type: "field" }> {
return block.type === "field";
}
function getToolMessageShape(
meta: AppMessage["meta"],
): ToolMessageShape | null {
if (!meta || typeof meta !== "object") {
return null;
}
const toolName =
typeof meta.toolName === "string" ? meta.toolName : undefined;
const input =
typeof meta.input === "string" ? meta.input : undefined;
if (!toolName || !input) {
return null;
}
return {
toolName,
input,
result:
typeof meta.result === "string" ? meta.result : undefined,
error:
typeof meta.error === "string" ? meta.error : undefined,
status:
meta.status === "pending" ||
meta.status === "running" ||
meta.status === "complete" ||
meta.status === "error"
? meta.status
: undefined,
progress:
typeof meta.progress === "string" ? meta.progress : undefined,
collapsed:
typeof meta.collapsed === "boolean" ? meta.collapsed : true,
};
}
function isRecord(value: unknown): value is Record<string, unknown> {
return (
typeof value === "object" &&
value !== null &&
!Array.isArray(value)
);
}
function stringArray(value: unknown): string[] {
return Array.isArray(value)
? value.filter((item): item is string => typeof item === "string")
: [];
}
function summarizeNames(names: string[]): string {
if (names.length === 0) {
return "";
}
const visible = names.slice(0, 4).join(", ");
return names.length > 4 ? `${visible}, ...` : visible;
}
function attachmentSummaryFromMeta(
meta: AppMessage["meta"],
): string | null {
if (!isRecord(meta)) {
return null;
}
const attachmentType =
typeof meta.attachment_type === "string"
? meta.attachment_type
: undefined;
const type = typeof meta.type === "string" ? meta.type : undefined;
if (type !== "attachment" && !attachmentType) {
return null;
}
const attachment = isRecord(meta.attachment) ? meta.attachment : {};
const resolvedType =
attachmentType ??
(typeof attachment.type === "string" ? attachment.type : undefined);
if (resolvedType === "agent_listing_delta") {
const names = stringArray(attachment.addedTypes);
const detail = summarizeNames(names);
return `Context: ${names.length} agent${names.length === 1 ? "" : "s"} available${detail ? ` (${detail})` : ""}`;
}
if (resolvedType === "skill_listing") {
const names = stringArray(attachment.skillNames);
const count =
typeof attachment.skillCount === "number"
? attachment.skillCount
: names.length;
const detail = summarizeNames(names);
return `Context: ${count} skill${count === 1 ? "" : "s"} available${detail ? ` (${detail})` : ""}`;
}
if (resolvedType === "deferred_tools_delta") {
const names = stringArray(attachment.addedNames);
const detail = summarizeNames(names);
return `Context: ${names.length} tool${names.length === 1 ? "" : "s"} available${detail ? ` (${detail})` : ""}`;
}
if (resolvedType) {
return `Context: ${resolvedType.replaceAll("_", " ")}`;
}
return "Context update";
}
function activitySummaryFromMeta(
meta: AppMessage["meta"],
text: string,
): string | null {
if (!isRecord(meta) || meta.activity !== true) {
return null;
}
const label =
typeof meta.activityLabel === "string" && meta.activityLabel.length > 0
? meta.activityLabel
: "Activity";
const cleaned = text.replace(/\s+/g, " ").trim();
return cleaned ? `${label}: ${cleaned}` : label;
}
function systemReminderSummary(text: string): string | null {
const trimmed = text.trimStart();
if (!trimmed.startsWith("<system-reminder>")) {
return null;
}
if (trimmed.includes("Available agent types")) {
return "Context: agents available";
}
if (trimmed.includes("Available skills")) {
return "Context: skills available";
}
if (trimmed.includes("deferred tools")) {
return "Context: tools available";
}
return "Context update";
}
export function isContextAttachmentMessage(
message: Pick<AppMessage, "meta" | "text" | "content">,
): boolean {
return getContextAttachmentSummary(message) !== null;
}
export function getContextAttachmentSummary(
message: Pick<AppMessage, "meta" | "text" | "content">,
): string | null {
const text = getMessageText(message);
return (
activitySummaryFromMeta(message.meta, text) ??
attachmentSummaryFromMeta(message.meta) ??
systemReminderSummary(text) ??
(message.meta?.hasReasoning === true && text.trim().length === 0
? "Thinking"
: null)
);
}
export function Message({
message,
expanded = false,
}: Props): React.ReactElement {
const color = getColor(MESSAGE_ROLE_TOKENS[message.role]);
const contentText = getMessageText(message);
const attachmentSummary = getContextAttachmentSummary(message);
const contentToolBlock = message.content.find(isToolUseBlock);
const toolMessageShape =
message.role === "tool"
? getToolMessageShape(message.meta) ??
(contentToolBlock
? {
toolName:
typeof contentToolBlock.tool_name === "string"
? contentToolBlock.tool_name
: "tool",
input: stringifyUnknown(contentToolBlock.tool_input ?? {}),
result:
typeof contentToolBlock.result === "string"
? contentToolBlock.result
: undefined,
error:
typeof contentToolBlock.error === "string"
? contentToolBlock.error
: undefined,
status: contentToolBlock.status,
progress:
typeof contentToolBlock.summary === "string"
? contentToolBlock.summary
: undefined,
collapsed: true,
}
: null)
: null;
if (attachmentSummary) {
return (
<Text color={getColor("textDim") as never}>
{attachmentSummary}
</Text>
);
}
if (toolMessageShape) {
return (
<CollapsibleToolCall
toolName={toolMessageShape.toolName}
input={toolMessageShape.input}
result={toolMessageShape.result}
error={toolMessageShape.error}
status={toolMessageShape.status}
progress={toolMessageShape.progress}
collapsed={expanded ? false : (toolMessageShape.collapsed ?? true)}
/>
);
}
const structuredDisplayText =
message.role === "user"
? null
: formatStructuredTextForDisplay(contentText, {
allowGenericRecord: message.role !== "assistant",
});
const displayText = structuredDisplayText ?? contentText;
if (message.meta?.streaming === true) {
return (
<StreamingText
text={displayText.trim().length > 0 ? displayText : "Thinking"}
streaming={true}
color={color}
/>
);
}
const fieldBlocks = message.content.filter(isFieldBlock);
if (fieldBlocks.length > 0 && displayText.length === 0) {
return (
<Box flexDirection="column">
{fieldBlocks.map((block, index) => (
<Text
key={`${message.id}:field:${index}`}
color={color as never}
>
{block.label}: {block.value}
</Text>
))}
</Box>
);
}
return (
<Text color={color as never}>
{displayText.length === 0 ? " " : displayText}
</Text>
);
}

View file

@ -0,0 +1,36 @@
import React from "react";
import { Box, Text } from "ink";
import type { AppMessage } from "../state/AppStateStore.js";
import { getColor } from "./design-system/theme.js";
const TIME_FORMATTER = new Intl.DateTimeFormat(undefined, {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
type Props = {
message: AppMessage;
};
function formatTimestamp(timestamp: number): string {
try {
return TIME_FORMATTER.format(new Date(timestamp));
} catch {
return "--:--:--";
}
}
export function MessageTimestamp({
message,
}: Props): React.ReactElement {
const formattedTimestamp = formatTimestamp(message.timestamp);
return (
<Box minWidth={formattedTimestamp.length}>
<Text color={getColor("textDim")}>
{formattedTimestamp}
</Text>
</Box>
);
}

View file

@ -0,0 +1,607 @@
import React from "react";
import { Box, Text } from "ink";
import { useTerminalSize } from "../hooks/useTerminalSize.js";
import { useRegisterKeybindingContext } from "../keybindings/KeybindingContext.js";
import type { ScrollBoxHandle } from "../ink/components/ScrollBox.js";
import type { AppMessage } from "../state/AppStateStore.js";
import { getMessageText } from "../screens/shared.js";
import { ScrollChromeContext } from "./FullscreenLayout.js";
import { ScrollKeybindingHandler } from "./ScrollKeybindingHandler.js";
import {
MessageActionsBar,
MessageActionsKeybindings,
useMessageActions,
type MessageActionsState,
type MessageActionsNav,
} from "./messageActions.js";
import { getColor } from "./design-system/theme.js";
import type { JumpHandle } from "./VirtualMessageList.js";
import {
buildTranscriptRows,
estimateTranscriptRows,
type TranscriptRow,
} from "./messages/transcriptRows.js";
const STICKY_PROMPT_CAP = 180;
type Props = {
messages: AppMessage[];
maxRows: number;
scrollRef?: React.RefObject<ScrollBoxHandle | null>;
jumpRef?: React.RefObject<JumpHandle | null>;
trackStickyPrompt?: boolean;
onSearchMatchesChange?: (count: number, current: number) => void;
cursorNavRef?: React.RefObject<MessageActionsNav | null>;
onCursorChange?: (cursor: MessageActionsState | null) => void;
emptyLabel?: string;
showAll?: boolean;
};
export function getMessageEstimateColumns(columns: number): number {
return Math.max(24, columns - 2);
}
function hasVisibleMessageContent(message: AppMessage): boolean {
if (message.meta?.hasReasoning === true) {
return true;
}
if (
message.content.some(
block => block.type === "field" || block.type === "tool_use",
)
) {
return true;
}
return getMessageText(message).trim().length > 0;
}
function isRenderableMessage(message: AppMessage): boolean {
if (message.meta?.hidden === true) {
return false;
}
if (message.meta?.budget === true) {
return false;
}
return hasVisibleMessageContent(message);
}
export function estimateMessagesContentHeight(
messages: AppMessage[],
columns: number,
options?: {
showAll?: boolean;
},
): number {
const allRenderableMessages = messages.filter(isRenderableMessage);
const renderableMessages = allRenderableMessages;
if (renderableMessages.length === 0) {
return 2;
}
const estimateColumns = getMessageEstimateColumns(columns);
return estimateTranscriptRows(renderableMessages, estimateColumns, {
showAll: options?.showAll,
});
}
function createMessageOffsets(
rows: TranscriptRow[],
): Map<string, number> {
const offsets = new Map<string, number>();
rows.forEach((row, index) => {
if (row.messageId && !offsets.has(row.messageId)) {
offsets.set(row.messageId, index);
}
});
return offsets;
}
function clampIndex(index: number, total: number): number {
return Math.max(0, Math.min(total - 1, index));
}
function createStickyPromptText(message: AppMessage): string | null {
if (message.role !== "user") {
return null;
}
const text = getMessageText(message).trim();
if (!text) {
return null;
}
return text.length <= STICKY_PROMPT_CAP
? text
: `${text.slice(0, STICKY_PROMPT_CAP - 1)}`;
}
export function Messages({
messages,
maxRows,
scrollRef,
jumpRef,
trackStickyPrompt = true,
onSearchMatchesChange,
cursorNavRef,
onCursorChange,
emptyLabel = "No messages yet. Type a prompt and press Enter.",
showAll = false,
}: Props): React.ReactElement {
const scrollChrome = React.useContext(ScrollChromeContext);
const setStickyPrompt = scrollChrome?.setStickyPrompt;
const terminalSize = useTerminalSize();
const allRenderableMessages = React.useMemo(
() => messages.filter(isRenderableMessage),
[messages],
);
const renderableMessages = React.useMemo(
() => allRenderableMessages,
[allRenderableMessages],
);
const internalCursorNavRef = React.useRef<MessageActionsNav | null>(null);
const resolvedCursorNavRef: React.RefObject<MessageActionsNav | null> =
cursorNavRef ?? internalCursorNavRef;
const [messageCursor, setMessageCursor] =
React.useState<MessageActionsState | null>(null);
const { handlers } = useMessageActions(
messageCursor,
setMessageCursor,
resolvedCursorNavRef,
);
const [, forceScrollRefresh] = React.useReducer(
(value: number) => value + 1,
0,
);
const unsubscribeRef = React.useRef<(() => void) | null>(null);
const subscribedHandleRef =
React.useRef<ScrollBoxHandle | null>(null);
const searchQueryRef = React.useRef("");
const searchMatchesRef = React.useRef<number[]>([]);
const currentSearchMatchRef = React.useRef(-1);
React.useEffect(() => {
onCursorChange?.(messageCursor);
}, [messageCursor, onCursorChange]);
React.useLayoutEffect(() => {
const handle = scrollRef?.current ?? null;
if (handle === subscribedHandleRef.current) {
return;
}
unsubscribeRef.current?.();
subscribedHandleRef.current = handle;
unsubscribeRef.current =
handle?.subscribe(() => {
forceScrollRefresh();
}) ?? null;
return () => {
unsubscribeRef.current?.();
unsubscribeRef.current = null;
subscribedHandleRef.current = null;
};
});
useRegisterKeybindingContext(
"MessageActions",
messageCursor !== null,
);
const estimateColumns = getMessageEstimateColumns(terminalSize.columns);
const expandedMessageIds = React.useMemo(() => {
const expanded = new Set<string>();
if (messageCursor?.expanded === true) {
expanded.add(messageCursor.id);
}
return expanded;
}, [messageCursor?.expanded, messageCursor?.id]);
const rows = React.useMemo(
() =>
buildTranscriptRows(renderableMessages, estimateColumns, {
showAll,
expandedMessageIds,
}),
[estimateColumns, expandedMessageIds, renderableMessages, showAll],
);
const messageOffsets = React.useMemo(
() => createMessageOffsets(rows),
[rows],
);
const loweredSearchTexts = React.useMemo(
() =>
renderableMessages.map(message =>
`${message.role} ${getMessageText(message)}`.toLowerCase(),
),
[renderableMessages],
);
const scrollHandle = scrollRef?.current ?? null;
const isStickyToBottom = scrollHandle?.isSticky() ?? true;
const viewportRows = Math.max(1, maxRows);
const maxScrollTop = Math.max(0, rows.length - viewportRows);
const scrollTop = Math.max(
0,
Math.min(
maxScrollTop,
isStickyToBottom
? maxScrollTop
: scrollHandle?.getScrollTop() ?? 0,
),
);
const visibleRows = rows.slice(scrollTop, scrollTop + viewportRows);
const firstVisibleMessageIndex =
visibleRows.find(
(row): row is TranscriptRow & { messageIndex: number } =>
typeof row.messageIndex === "number",
)?.messageIndex ?? null;
const leadingBlankRows =
rows.length < viewportRows ? viewportRows - rows.length : 0;
const emptyBlankRows =
renderableMessages.length === 0 ? Math.max(0, viewportRows - 1) : 0;
const scrollToIndex = React.useCallback(
(index: number) => {
const handle = scrollRef?.current;
if (!handle || renderableMessages.length === 0) {
return;
}
const boundedIndex = clampIndex(index, renderableMessages.length);
const message = renderableMessages[boundedIndex];
if (!message) {
return;
}
handle.scrollTo(Math.max(0, (messageOffsets.get(message.id) ?? 0) - 1));
},
[messageOffsets, renderableMessages, scrollRef],
);
const selectIndex = React.useCallback(
(index: number, preserveExpanded = false) => {
if (renderableMessages.length === 0) {
setMessageCursor(null);
return;
}
const boundedIndex = clampIndex(index, renderableMessages.length);
const message = renderableMessages[boundedIndex];
if (!message) {
setMessageCursor(null);
return;
}
setMessageCursor(prev => ({
id: message.id,
expanded:
preserveExpanded && prev?.id === message.id ? prev.expanded : false,
}));
},
[renderableMessages],
);
React.useEffect(() => {
if (!messageCursor) {
return;
}
if (!renderableMessages.some(message => message.id === messageCursor.id)) {
setMessageCursor(null);
}
}, [messageCursor, renderableMessages]);
React.useEffect(() => {
if (!setStickyPrompt) {
return;
}
if (
!trackStickyPrompt ||
isStickyToBottom ||
scrollTop <= 0 ||
firstVisibleMessageIndex === null
) {
setStickyPrompt(null);
return;
}
for (
let index = Math.min(
firstVisibleMessageIndex,
renderableMessages.length - 1,
);
index >= 0;
index -= 1
) {
const message = renderableMessages[index];
if (!message) {
continue;
}
const promptText = createStickyPromptText(message);
if (!promptText) {
continue;
}
setStickyPrompt({
sourceId: message.id,
text: promptText,
scrollTo: () => {
scrollToIndex(index);
selectIndex(index, true);
},
});
return;
}
setStickyPrompt(null);
}, [
firstVisibleMessageIndex,
renderableMessages,
isStickyToBottom,
setStickyPrompt,
scrollToIndex,
scrollTop,
selectIndex,
trackStickyPrompt,
]);
const recomputeSearchMatches = React.useCallback(
(query: string) => {
const normalized = query.trim().toLowerCase();
searchQueryRef.current = normalized;
if (!normalized) {
searchMatchesRef.current = [];
currentSearchMatchRef.current = -1;
onSearchMatchesChange?.(0, 0);
return;
}
const matches: number[] = [];
loweredSearchTexts.forEach((text, index) => {
if (text.includes(normalized)) {
matches.push(index);
}
});
searchMatchesRef.current = matches;
currentSearchMatchRef.current = matches.length > 0 ? 0 : -1;
onSearchMatchesChange?.(
matches.length,
matches.length > 0 ? 1 : 0,
);
if (matches.length > 0) {
const matchIndex = matches[0]!;
scrollToIndex(matchIndex);
selectIndex(matchIndex, true);
}
},
[loweredSearchTexts, onSearchMatchesChange, scrollToIndex, selectIndex],
);
React.useImperativeHandle(
jumpRef,
() => ({
jumpToIndex(index: number) {
scrollToIndex(index);
selectIndex(index, true);
},
setSearchQuery(query: string) {
recomputeSearchMatches(query);
},
nextMatch() {
const matches = searchMatchesRef.current;
if (matches.length === 0) {
return;
}
currentSearchMatchRef.current =
(currentSearchMatchRef.current + 1) % matches.length;
const matchIndex =
matches[currentSearchMatchRef.current] ?? matches[0]!;
onSearchMatchesChange?.(
matches.length,
currentSearchMatchRef.current + 1,
);
scrollToIndex(matchIndex);
selectIndex(matchIndex, true);
},
prevMatch() {
const matches = searchMatchesRef.current;
if (matches.length === 0) {
return;
}
currentSearchMatchRef.current =
(currentSearchMatchRef.current - 1 + matches.length) %
matches.length;
const matchIndex =
matches[currentSearchMatchRef.current] ??
matches[matches.length - 1]!;
onSearchMatchesChange?.(
matches.length,
currentSearchMatchRef.current + 1,
);
scrollToIndex(matchIndex);
selectIndex(matchIndex, true);
},
setAnchor() {
},
async warmSearchIndex() {
const start = performance.now();
loweredSearchTexts.length;
return Math.round(performance.now() - start);
},
disarmSearch() {
currentSearchMatchRef.current = -1;
if (searchQueryRef.current.length > 0) {
onSearchMatchesChange?.(
searchMatchesRef.current.length,
0,
);
}
},
}),
[
jumpRef,
loweredSearchTexts.length,
onSearchMatchesChange,
recomputeSearchMatches,
scrollToIndex,
selectIndex,
],
);
React.useImperativeHandle(
resolvedCursorNavRef,
() => ({
enterCursor() {
const visibleMessageIndexes = new Set(
visibleRows
.map(row => row.messageIndex)
.filter((index): index is number => typeof index === "number"),
);
const candidateIndexes =
visibleMessageIndexes.size > 0
? [
...Array.from(visibleMessageIndexes).sort((a, b) => b - a),
...Array.from(
{ length: renderableMessages.length },
(_, offset) => renderableMessages.length - offset - 1,
),
]
: Array.from(
{ length: renderableMessages.length },
(_, offset) => renderableMessages.length - offset - 1,
);
for (const index of candidateIndexes) {
if (!renderableMessages[index]) {
continue;
}
selectIndex(index, true);
scrollToIndex(index);
return;
}
},
navigatePrev() {
const currentIndex = messageCursor
? renderableMessages.findIndex(message => message.id === messageCursor.id)
: renderableMessages.length;
for (let index = currentIndex - 1; index >= 0; index -= 1) {
if (!renderableMessages[index]) {
continue;
}
selectIndex(index);
scrollToIndex(index);
return;
}
},
navigateNext() {
const currentIndex = messageCursor
? renderableMessages.findIndex(message => message.id === messageCursor.id)
: -1;
for (let index = currentIndex + 1; index < renderableMessages.length; index += 1) {
if (!renderableMessages[index]) {
continue;
}
selectIndex(index);
scrollToIndex(index);
return;
}
},
navigateTop() {
if (renderableMessages.length === 0) {
return;
}
selectIndex(0);
scrollToIndex(0);
},
navigateBottom() {
if (renderableMessages.length === 0) {
return;
}
const lastIndex = renderableMessages.length - 1;
selectIndex(lastIndex);
scrollToIndex(lastIndex);
},
getSelected() {
return messageCursor
? renderableMessages.find(message => message.id === messageCursor.id) ?? null
: null;
},
}),
[
messageCursor,
renderableMessages,
resolvedCursorNavRef,
scrollToIndex,
selectIndex,
visibleRows,
],
);
return (
<Box flexDirection="column">
<ScrollKeybindingHandler
scrollRef={scrollRef}
isActive={true}
/>
<MessageActionsKeybindings
handlers={handlers}
isActive={messageCursor !== null}
/>
<Box flexDirection="column">
{renderableMessages.length === 0 ? (
<>
{Array.from({ length: emptyBlankRows }, (_, index) => (
<Text key={`empty-blank:${index}`}> </Text>
))}
<Text color={getColor("textDim")}>
{emptyLabel}
</Text>
</>
) : (
<>
{Array.from({ length: leadingBlankRows }, (_, index) => (
<Text key={`blank:${index}`}> </Text>
))}
{visibleRows.map(row => {
const selected = row.messageId === messageCursor?.id;
return (
<Text
key={row.key}
color={getColor(row.colorToken) as never}
bold={row.bold}
dimColor={row.dim}
backgroundColor={selected ? (getColor("bgHighlight") as never) : undefined}
>
{row.text.length > 0 ? row.text : " "}
</Text>
);
})}
</>
)}
</Box>
{messageCursor ? (
<MessageActionsBar cursor={messageCursor} />
) : null}
</Box>
);
}

View file

@ -0,0 +1,49 @@
import React from "react";
import { Box, Text } from "ink";
import type { Notification } from "../context/notifications.js";
import { getColor } from "./design-system/theme.js";
type NotificationBannerProps = {
notification: Notification | null;
};
function resolveNotificationColor(notification: Notification): string {
if ("color" in notification && notification.color) {
return notification.color;
}
switch (notification.priority) {
case "immediate":
case "high":
return getColor("error");
case "medium":
return getColor("warning");
case "low":
default:
return getColor("primary");
}
}
function resolveNotificationText(notification: Notification): string | null {
if ("text" in notification) {
return notification.text;
}
return null;
}
export function NotificationBanner({
notification,
}: NotificationBannerProps): React.ReactElement | null {
if (!notification) return null;
const text = resolveNotificationText(notification);
if (!text) return null;
const color = resolveNotificationColor(notification);
return (
<Box borderStyle="round" borderColor={color} paddingX={1}>
<Text color={color}>{text}</Text>
</Box>
);
}

View file

@ -0,0 +1,10 @@
import React from "react";
import { Text } from "ink";
export function PressEnterToContinue(): React.ReactElement {
return (
<Text dimColor>
Press Enter to continue
</Text>
);
}

View file

@ -0,0 +1,145 @@
import React from "react";
import { Box, Text } from "ink";
import { useRegisterOverlay } from "../../context/overlayContext.js";
import { useSetPromptOverlay } from "../../context/promptOverlayContext.js";
import { useTerminalSize } from "../../hooks/useTerminalSize.js";
import type { InputMode } from "../../state/AppStateStore.js";
import type { SandboxStatusData } from "../../bridge/protocol.js";
import type { VimMode } from "../../types/textInputTypes.js";
import type { CompletionItem } from "./SlashCommandComplete.js";
import { SlashCommandComplete } from "./SlashCommandComplete.js";
import PromptInputFooter from "./PromptInputFooter.js";
import { PromptInputModeIndicator } from "./PromptInputModeIndicator.js";
type Props = {
value: string;
disabled: boolean;
busy?: boolean;
inputMode: InputMode;
vimMode?: VimMode;
cursorOffset?: number;
placeholder?: string;
suggestions?: CompletionItem[];
selectedSuggestion?: number;
showSuggestions?: boolean;
renderSuggestionsInline?: boolean;
publishSuggestionsOverlay?: boolean;
sandbox?: SandboxStatusData;
};
function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}
function renderWithCursor(
value: string,
cursorOffset: number,
disabled: boolean,
): React.ReactElement {
const boundedOffset = clamp(cursorOffset, 0, value.length);
const before = value.slice(0, boundedOffset);
const cursor = value[boundedOffset] ?? " ";
const after =
boundedOffset < value.length ? value.slice(boundedOffset + 1) : "";
return (
<Text>
<Text>{before}</Text>
<Text inverse={!disabled}>{cursor}</Text>
<Text>{after}</Text>
</Text>
);
}
export default function PromptInput({
value,
disabled,
busy = false,
inputMode,
vimMode,
cursorOffset = value.length,
placeholder,
suggestions = [],
selectedSuggestion = 0,
showSuggestions = false,
renderSuggestionsInline = true,
publishSuggestionsOverlay = true,
sandbox,
}: Props): React.ReactElement {
const terminalSize = useTerminalSize();
const color =
inputMode === "command"
? "cyan"
: vimMode === "NORMAL"
? "yellow"
: "green";
const shouldRenderOverlay =
publishSuggestionsOverlay &&
showSuggestions &&
!renderSuggestionsInline &&
suggestions.length > 0;
const emptyPlaceholder =
placeholder ??
(disabled ? "Waiting for the current task..." : "Type a prompt and press Enter");
useRegisterOverlay("autocomplete", shouldRenderOverlay);
useSetPromptOverlay(
shouldRenderOverlay
? {
suggestions,
selectedSuggestion,
maxColumnWidth: Math.max(24, Math.floor(terminalSize.columns * 0.7)),
}
: null,
);
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor={disabled ? "gray" : (color as never)}
borderLeft={false}
borderRight={false}
borderBottom={false}
paddingX={1}
marginTop={1}
width="100%"
flexShrink={0}
>
<Box alignItems="flex-start" width="100%">
<PromptInputModeIndicator
inputMode={inputMode}
disabled={disabled}
vimMode={vimMode}
/>
<Box flexDirection="column" flexGrow={1} flexShrink={1}>
{value ? (
renderWithCursor(value, cursorOffset, disabled)
) : (
<Text color="gray" wrap="truncate">{emptyPlaceholder}</Text>
)}
</Box>
</Box>
{showSuggestions && renderSuggestionsInline ? (
<Box marginTop={1}>
<SlashCommandComplete
items={suggestions}
selectedIndex={selectedSuggestion}
visible={showSuggestions}
bordered={false}
/>
</Box>
) : null}
<PromptInputFooter
disabled={disabled}
busy={busy}
inputMode={inputMode}
vimMode={vimMode}
suggestionsVisible={showSuggestions}
sandbox={sandbox}
/>
</Box>
);
}

View file

@ -0,0 +1,82 @@
import React from "react";
import { Box, Text } from "ink";
import { useShortcutDisplay } from "../../keybindings/useShortcutDisplay.js";
import type { InputMode } from "../../state/AppStateStore.js";
import type { SandboxStatusData } from "../../bridge/protocol.js";
import type { VimMode } from "../../types/textInputTypes.js";
import { sandboxHint } from "../../utils/sandboxPromptFooter.js";
type Props = {
disabled: boolean;
busy?: boolean;
inputMode: InputMode;
vimMode?: VimMode;
suggestionsVisible: boolean;
sandbox?: SandboxStatusData;
};
export default function PromptInputFooter({
disabled,
busy = false,
inputMode,
vimMode,
suggestionsVisible,
sandbox,
}: Props): React.ReactElement {
const submitShortcut = useShortcutDisplay("chat:submit", "Chat", "Enter");
const newlineShortcut = useShortcutDisplay("chat:newline", "Chat", "shift+Enter");
const cancelShortcut = useShortcutDisplay("app:interrupt", "Global", "ctrl+c");
const autocompleteAcceptShortcut = useShortcutDisplay(
"autocomplete:accept",
"Autocomplete",
"Tab",
);
const autocompletePrevShortcut = useShortcutDisplay(
"autocomplete:previous",
"Autocomplete",
"Up",
);
const autocompleteNextShortcut = useShortcutDisplay(
"autocomplete:next",
"Autocomplete",
"Down",
);
const dismissShortcut = useShortcutDisplay(
"chat:cancel",
"Chat",
"Esc",
);
let hint = disabled
? "Waiting for the current task to finish"
: busy
? `Task running | ${cancelShortcut} cancel`
: `${submitShortcut} send | ${newlineShortcut} newline | ${cancelShortcut} cancel`;
if (!disabled && busy && inputMode === "command") {
hint = suggestionsVisible
? `${autocompleteAcceptShortcut} complete | ${autocompletePrevShortcut}/${autocompleteNextShortcut} select | ${cancelShortcut} cancel`
: `Task running | ${autocompleteAcceptShortcut} complete | ${cancelShortcut} cancel`;
} else if (!disabled && inputMode === "command") {
hint = suggestionsVisible
? `${autocompleteAcceptShortcut} complete | ${autocompletePrevShortcut}/${autocompleteNextShortcut} select | ${submitShortcut} run`
: `${submitShortcut} run | ${autocompleteAcceptShortcut} complete | ${dismissShortcut} clear`;
}
const sandboxStatus = sandboxHint(sandbox);
return (
<Box marginTop={1} width="100%" height={2} justifyContent="space-between">
<Box flexDirection="column" flexGrow={1} flexShrink={1}>
<Text color="gray" wrap="truncate">{hint}</Text>
{sandboxStatus ? (
<Text color={sandboxStatus.color} wrap="truncate">
{sandboxStatus.text}
</Text>
) : (
<Text> </Text>
)}
</Box>
{vimMode ? <Text color="gray">vim {vimMode}</Text> : null}
</Box>
);
}

View file

@ -0,0 +1,35 @@
import React from "react";
import { Box, Text } from "ink";
import type { InputMode } from "../../state/AppStateStore.js";
import type { VimMode } from "../../types/textInputTypes.js";
type Props = {
inputMode: InputMode;
disabled: boolean;
vimMode?: VimMode;
};
export function PromptInputModeIndicator({
inputMode,
disabled,
vimMode,
}: Props): React.ReactElement {
const prefix =
vimMode === "NORMAL"
? "N"
: ">";
const color =
inputMode === "command"
? "cyan"
: vimMode === "NORMAL"
? "yellow"
: "green";
return (
<Box marginRight={1}>
<Text color={disabled ? "gray" : (color as never)} dimColor={disabled}>
{prefix}
</Text>
</Box>
);
}

View file

@ -0,0 +1,121 @@
import React from "react";
import { Box, Text } from "ink";
import { getColor } from "../design-system/theme.js";
import { useTerminalSize } from "../../hooks/useTerminalSize.js";
import {
stringDisplayWidth,
truncateToDisplayWidth,
} from "../../utils/textWidth.js";
export type CompletionItem = {
name: string;
summary: string;
category?: string;
};
type SlashCommandCompleteProps = {
items: CompletionItem[];
selectedIndex: number;
visible: boolean;
maxVisibleItems?: number;
maxColumnWidth?: number;
bordered?: boolean;
};
function oneLine(value: string): string {
return value.replace(/\s+/g, " ").trim();
}
function padRight(value: string, width: number): string {
const padding = Math.max(0, width - stringDisplayWidth(value));
return `${value}${" ".repeat(padding)}`;
}
export function SlashCommandComplete({
items,
selectedIndex,
visible,
maxVisibleItems,
maxColumnWidth,
bordered = false,
}: SlashCommandCompleteProps): React.ReactElement | null {
const { columns } = useTerminalSize();
if (!visible || items.length === 0) return null;
const visibleCount = Math.max(
1,
Math.min(items.length, maxVisibleItems ?? items.length),
);
const startIndex = Math.max(
0,
Math.min(items.length - visibleCount, selectedIndex - Math.floor(visibleCount / 2)),
);
const visibleItems = items.slice(startIndex, startIndex + visibleCount);
const hasMore = visibleItems.length < items.length;
const contentWidth = Math.max(20, columns - (bordered ? 4 : 2));
const longestNameWidth = Math.max(
...items.map(item => stringDisplayWidth(`/${item.name}`)),
);
const nameColumnWidth = Math.min(
Math.max(12, longestNameWidth + 2),
maxColumnWidth ?? Math.max(12, Math.floor(contentWidth * 0.4)),
);
const summaryWidth = Math.max(0, contentWidth - nameColumnWidth - 5);
const list = (
<Box flexDirection="column" width="100%">
{visibleItems.map((item, offset) => {
const absoluteIndex = startIndex + offset;
const isSelected = absoluteIndex === selectedIndex;
const commandName = truncateToDisplayWidth(`/${item.name}`, nameColumnWidth - 1);
const paddedName = padRight(commandName, nameColumnWidth);
const summary = summaryWidth > 0
? truncateToDisplayWidth(oneLine(item.summary), summaryWidth)
: "";
return (
<Text key={item.name} wrap="truncate">
<Text
color={isSelected ? getColor("primary") : getColor("text")}
bold={isSelected}
dimColor={!isSelected}
>
{isSelected ? "> " : " "}
{paddedName}
</Text>
{summary ? (
<Text
color={isSelected ? getColor("primary") : getColor("textDim")}
dimColor={!isSelected}
>
{" - "}
{summary}
</Text>
) : null}
</Text>
);
})}
{hasMore ? (
<Text color={getColor("textDim")} wrap="truncate">
Showing {startIndex + 1}-{startIndex + visibleItems.length} of{" "}
{items.length}
</Text>
) : null}
</Box>
);
return bordered ? (
<Box
flexDirection="column"
borderStyle="single"
borderColor={getColor("borderFocused")}
paddingX={1}
width="100%"
>
{list}
</Box>
) : (
list
);
}

View file

@ -0,0 +1,7 @@
export { default as PromptInput } from "./PromptInput.js";
export { PromptInputModeIndicator } from "./PromptInputModeIndicator.js";
export { default as PromptInputFooter } from "./PromptInputFooter.js";
export {
SlashCommandComplete,
type CompletionItem,
} from "./SlashCommandComplete.js";

View file

@ -0,0 +1,5 @@
import type { InputMode } from "../../state/AppStateStore.js";
export function getModeFromInput(value: string): InputMode {
return value.trimStart().startsWith("/") ? "command" : "insert";
}

View file

@ -0,0 +1,94 @@
import React from "react";
import { useKeybindings } from "../keybindings/useKeybinding.js";
import type { ScrollBoxHandle } from "../ink/components/ScrollBox.js";
type Props = {
scrollRef?: React.RefObject<ScrollBoxHandle | null>;
isActive: boolean;
onScroll?: (sticky: boolean, handle: ScrollBoxHandle) => void;
};
function withHandle(
scrollRef: React.RefObject<ScrollBoxHandle | null> | undefined,
run: (handle: ScrollBoxHandle) => void,
): void {
const handle = scrollRef?.current;
if (!handle) {
return;
}
run(handle);
}
export function ScrollKeybindingHandler({
scrollRef,
isActive,
onScroll,
}: Props): null {
const scrollBy = React.useCallback(
(delta: number) => {
withHandle(scrollRef, handle => {
handle.scrollBy(delta);
onScroll?.(handle.isSticky(), handle);
});
},
[onScroll, scrollRef],
);
const pageSize = React.useCallback(
(handle: ScrollBoxHandle): number =>
Math.max(1, handle.getViewportHeight() - 2),
[],
);
const handlers = React.useMemo(
() => ({
"scroll:pageUp": () => {
withHandle(scrollRef, handle => {
handle.scrollBy(-pageSize(handle));
onScroll?.(handle.isSticky(), handle);
});
},
"scroll:pageDown": () => {
withHandle(scrollRef, handle => {
handle.scrollBy(pageSize(handle));
onScroll?.(handle.isSticky(), handle);
});
},
"scroll:top": () => {
withHandle(scrollRef, handle => {
handle.scrollTo(0);
onScroll?.(handle.isSticky(), handle);
});
},
"scroll:bottom": () => {
withHandle(scrollRef, handle => {
handle.scrollToBottom();
onScroll?.(handle.isSticky(), handle);
});
},
"scroll:wheelUp": () => {
scrollBy(-3);
},
"scroll:wheelDown": () => {
scrollBy(3);
},
}),
[onScroll, pageSize, scrollBy, scrollRef],
);
useKeybindings(handlers, {
context: "Chat",
isActive,
});
useKeybindings(handlers, {
context: "MessageActions",
isActive,
});
useKeybindings(handlers, {
context: "Transcript",
isActive,
});
return null;
}

View file

@ -0,0 +1,43 @@
import React from "react";
import { Text } from "ink";
const FRAMES = ["-", "\\", "|", "/"];
type SpinnerWithVerbProps = {
active: boolean;
message?: string;
color?: string;
};
export function SpinnerWithVerb({
active,
message = "Query running",
color = "yellow",
}: SpinnerWithVerbProps): React.ReactElement | null {
const [index, setIndex] = React.useState(0);
React.useEffect(() => {
if (!active) {
setIndex(0);
return;
}
const timer = setInterval(() => {
setIndex(current => (current + 1) % FRAMES.length);
}, 90);
return () => {
clearInterval(timer);
};
}, [active]);
if (!active) {
return null;
}
return (
<Text color={color as never}>
{FRAMES[index]} {message}
</Text>
);
}

View file

@ -0,0 +1,81 @@
import React from "react";
import { Box, Text } from "ink";
import { getColor } from "./design-system/theme.js";
import type { RuntimeState, TaskState } from "../state/AppStateStore.js";
type StatusBarProps = {
runtime: RuntimeState;
isQuerying: boolean;
tasks?: Record<string, TaskState>;
};
function formatUsd(cost: number | undefined): string {
if (cost === undefined || Number.isNaN(cost)) return "—";
return `$${cost.toFixed(4)}`;
}
function formatTokens(value: number | undefined): string {
if (value === undefined || Number.isNaN(value)) return "—";
return value.toLocaleString("en-US");
}
function formatActiveTaskPill(tasks: Record<string, TaskState>): string | null {
const running = Object.values(tasks).filter(t => t.status === "running");
if (running.length === 0) return null;
if (running.length === 1) return running[0]!.title ?? running[0]!.id;
return `${running.length} tasks`;
}
export function StatusBar({
runtime,
isQuerying,
tasks,
}: StatusBarProps): React.ReactElement {
const taskPill = tasks ? formatActiveTaskPill(tasks) : null;
const phaseColor = isQuerying ? getColor("warning") : getColor("muted");
return (
<Box flexDirection="column">
<Box>
<Text color={getColor("primary")} bold>
Model:{" "}
</Text>
<Text>{runtime.model ?? "n/a"}</Text>
<Text color={getColor("muted")}> </Text>
<Text color={getColor("primary")} bold>
Session:{" "}
</Text>
<Text>{runtime.sessionId ? runtime.sessionId.slice(0, 12) : "n/a"}</Text>
<Text color={getColor("muted")}> </Text>
<Text color={getColor("primary")} bold>
Cost:{" "}
</Text>
<Text>{formatUsd(runtime.costUsd)}</Text>
</Box>
<Box>
<Text color={getColor("primary")} bold>
Tokens:{" "}
</Text>
<Text>
{formatTokens(runtime.inputTokens)} in / {formatTokens(runtime.outputTokens)} out
</Text>
<Text color={getColor("muted")}> </Text>
<Text color={phaseColor} bold>
{isQuerying ? "● " : "○ "}
</Text>
<Text color={phaseColor}>{runtime.phase ?? "idle"}</Text>
{runtime.totalIterations !== undefined && runtime.maxIterations !== undefined ? (
<Text color={getColor("muted")}>
{" "}({runtime.totalIterations}/{runtime.maxIterations})
</Text>
) : null}
{taskPill ? (
<>
<Text color={getColor("muted")}> </Text>
<Text color={getColor("accent")}>{taskPill}</Text>
</>
) : null}
</Box>
</Box>
);
}

View file

@ -0,0 +1,94 @@
import React from "react";
import { Box, Text } from "ink";
import type {
AgentRuntimeState,
MCPClientState,
RuntimeState,
} from "../state/AppStateStore.js";
import type { VimMode } from "../types/textInputTypes.js";
import { formatTokens, formatUsd } from "../screens/shared.js";
import { CoordinatorStatusBar } from "./CoordinatorStatusBar.js";
type Props = {
runtime: RuntimeState;
mcpClientStates?: MCPClientState[];
agents?: AgentRuntimeState;
vimMode?: VimMode;
};
function formatSandboxSummary(runtime: RuntimeState): string {
const sandbox = runtime.sandbox;
if (!sandbox) {
return "n/a";
}
if (sandbox.sandboxing_enabled) {
return sandbox.mode === "auto-allow"
? "on auto"
: sandbox.mode === "regular"
? "on regular"
: "on";
}
if (sandbox.enabled_in_settings) {
return sandbox.status === "fail" ? "fail" : "warn";
}
return "off";
}
export function StatusLine({
runtime,
mcpClientStates = [],
agents,
vimMode,
}: Props): React.ReactElement {
const connectedMcp = mcpClientStates.filter(
client => client.status === "connected",
).length;
const failingMcp = mcpClientStates.filter(
client => client.status === "error",
).length;
const tokenWarning = runtime.tokenWarning;
const showTokenWarning =
tokenWarning?.is_above_warning_threshold === true;
const tokenWarningColor =
tokenWarning?.is_above_error_threshold === true ||
tokenWarning?.is_at_blocking_limit === true
? "red"
: "yellow";
const tokenWarningText = tokenWarning
? tokenWarning.is_above_auto_compact_threshold
? `Context compacting (${tokenWarning.percent_left}% left)`
: `Context low (${tokenWarning.percent_left}% left)`
: null;
return (
<Box flexDirection="column" height={4} overflowY="hidden">
<Text bold color="cyan">
OpenSpace | {runtime.model ?? "model n/a"} | {runtime.phase ?? "idle"} | Cost{" "}
{formatUsd(runtime.costUsd)}
</Text>
<Text color="gray">
Session {runtime.sessionId ?? "n/a"} | Task {runtime.activeTaskId ?? "n/a"} | Tokens{" "}
{formatTokens(runtime.inputTokens)} / {formatTokens(runtime.outputTokens)} |{" "}
Iterations {runtime.totalIterations ?? 0}
{runtime.maxIterations !== undefined ? ` / ${runtime.maxIterations}` : ""} | MCP{" "}
{connectedMcp}/{mcpClientStates.length}
{failingMcp > 0 ? ` (${failingMcp} error)` : ""}
{" | "}Sandbox {formatSandboxSummary(runtime)}
{vimMode ? ` | Vim ${vimMode}` : ""}
</Text>
<Box height={1}>
{showTokenWarning && tokenWarningText ? (
<Text color={tokenWarningColor as never}>{tokenWarningText}</Text>
) : null}
</Box>
<Box height={1}>
{agents ? (
<CoordinatorStatusBar
coordinator={agents.coordinator}
backgroundTasks={agents.backgroundTasks}
/>
) : null}
</Box>
</Box>
);
}

View file

@ -0,0 +1,572 @@
import React from "react";
import { Box } from "ink";
import { ScrollChromeContext } from "./FullscreenLayout.js";
import {
InVirtualListContext,
isNavigableMessage as defaultIsNavigableMessage,
MessageActionsSelectedContext,
type MessageActionsNav,
type MessageActionsState,
} from "./messageActions.js";
import type { AppMessage } from "../state/AppStateStore.js";
import type { ScrollBoxHandle } from "../ink/components/ScrollBox.js";
import { getMessageText } from "../screens/shared.js";
const WINDOW_OVERSCAN_ROWS = 6;
const STICKY_PROMPT_CAP = 180;
const SCROLL_HEADROOM = 2;
export type JumpHandle = {
jumpToIndex: (index: number) => void;
setSearchQuery: (query: string) => void;
nextMatch: () => void;
prevMatch: () => void;
setAnchor: () => void;
warmSearchIndex: () => Promise<number>;
disarmSearch: () => void;
};
type Props = {
messages: AppMessage[];
scrollRef?: React.RefObject<ScrollBoxHandle | null>;
maxRows: number;
itemKey: (message: AppMessage) => string;
estimateItemHeight: (message: AppMessage, index: number) => number;
renderItem: (message: AppMessage, index: number) => React.ReactNode;
isItemNavigable?: (message: AppMessage) => boolean;
cursor: MessageActionsState | null;
setCursor: React.Dispatch<
React.SetStateAction<MessageActionsState | null>
>;
cursorNavRef?: React.Ref<MessageActionsNav | null>;
jumpRef?: React.Ref<JumpHandle | null>;
extractSearchText?: (message: AppMessage) => string;
onSearchMatchesChange?: (count: number, current: number) => void;
trackStickyPrompt?: boolean;
};
function VerticalSpacer({
rows,
}: {
rows: number;
}): React.ReactElement | null {
if (rows <= 0) {
return null;
}
return <Box height={rows} flexShrink={0} />;
}
function findStartIndex(
offsets: number[],
heights: number[],
topRow: number,
): number {
let low = 0;
let high = offsets.length - 1;
let result = offsets.length;
while (low <= high) {
const middle = Math.floor((low + high) / 2);
const rowTop = offsets[middle] ?? 0;
const rowBottom = rowTop + (heights[middle] ?? 0);
if (rowBottom > topRow) {
result = middle;
high = middle - 1;
} else {
low = middle + 1;
}
}
return result;
}
function findEndIndex(
offsets: number[],
heights: number[],
bottomRow: number,
): number {
let low = 0;
let high = offsets.length - 1;
let result = offsets.length;
while (low <= high) {
const middle = Math.floor((low + high) / 2);
const rowTop = offsets[middle] ?? 0;
if (rowTop >= bottomRow) {
result = middle;
high = middle - 1;
} else if (rowTop + (heights[middle] ?? 0) > bottomRow) {
result = middle + 1;
high = middle - 1;
} else {
low = middle + 1;
}
}
return result;
}
function clampIndex(index: number, total: number): number {
return Math.max(0, Math.min(total - 1, index));
}
function createStickyPromptText(
message: AppMessage,
): string | null {
if (message.role !== "user") {
return null;
}
const text = getMessageText(message).trim();
if (!text) {
return null;
}
if (text.length <= STICKY_PROMPT_CAP) {
return text;
}
return `${text.slice(0, STICKY_PROMPT_CAP - 1)}`;
}
export function VirtualMessageList({
messages,
scrollRef,
maxRows,
itemKey,
estimateItemHeight,
renderItem,
isItemNavigable = defaultIsNavigableMessage,
cursor,
setCursor,
cursorNavRef,
jumpRef,
extractSearchText,
onSearchMatchesChange,
trackStickyPrompt = false,
}: Props): React.ReactElement {
const scrollChrome = React.useContext(ScrollChromeContext);
const [, forceWindowRefresh] = React.useReducer(
(value: number) => value + 1,
0,
);
const unsubscribeRef = React.useRef<(() => void) | null>(null);
const subscribedHandleRef =
React.useRef<ScrollBoxHandle | null>(null);
const searchQueryRef = React.useRef("");
const searchAnchorRef = React.useRef<number | null>(null);
const searchMatchesRef = React.useRef<number[]>([]);
const currentSearchMatchRef = React.useRef<number>(-1);
React.useLayoutEffect(() => {
const handle = scrollRef?.current ?? null;
if (handle === subscribedHandleRef.current) {
return;
}
unsubscribeRef.current?.();
subscribedHandleRef.current = handle;
unsubscribeRef.current =
handle?.subscribe(() => {
forceWindowRefresh();
}) ?? null;
return () => {
unsubscribeRef.current?.();
unsubscribeRef.current = null;
subscribedHandleRef.current = null;
};
});
const rowHeights = React.useMemo(
() =>
messages.map((message, index) =>
estimateItemHeight(message, index),
),
[estimateItemHeight, messages],
);
const rowOffsets = React.useMemo(() => {
const offsets: number[] = [];
let total = 0;
for (const height of rowHeights) {
offsets.push(total);
total += height;
}
return offsets;
}, [rowHeights]);
const totalRows = rowHeights.reduce(
(sum, height) => sum + height,
0,
);
const scrollHandle = scrollRef?.current ?? null;
const viewportRows =
scrollHandle?.getViewportHeight() ??
Math.max(1, maxRows);
const maxScrollTop = Math.max(0, totalRows - viewportRows);
const shouldStickToBottom = scrollHandle?.isSticky() ?? true;
const scrollTop = Math.max(
0,
Math.min(
maxScrollTop,
shouldStickToBottom
? maxScrollTop
: (scrollHandle?.getScrollTop() ?? maxScrollTop) +
(scrollHandle?.getPendingDelta() ?? 0),
),
);
const windowTop = scrollTop;
const windowBottom = scrollTop + viewportRows + WINDOW_OVERSCAN_ROWS;
const startIndex = findStartIndex(rowOffsets, rowHeights, windowTop);
const endIndex = Math.max(
startIndex,
findEndIndex(rowOffsets, rowHeights, windowBottom),
);
const visibleRows = rowHeights
.slice(startIndex, endIndex)
.reduce((sum, height) => sum + height, 0);
const topSpacerRows =
startIndex < rowOffsets.length ? (rowOffsets[startIndex] ?? 0) : totalRows;
const bottomSpacerRows = Math.max(
0,
totalRows - topSpacerRows - visibleRows,
);
const visibleMessages = messages.slice(startIndex, endIndex);
const loweredSearchTexts = React.useMemo(
() =>
messages.map(message =>
(extractSearchText?.(message) ?? getMessageText(message)).toLowerCase(),
),
[extractSearchText, messages],
);
const scrollToIndex = React.useCallback(
(index: number) => {
const handle = scrollRef?.current;
if (!handle || messages.length === 0) {
return;
}
const boundedIndex = clampIndex(index, messages.length);
const targetTop =
Math.max(0, (rowOffsets[boundedIndex] ?? 0) - SCROLL_HEADROOM);
handle.scrollTo(targetTop);
},
[messages.length, rowOffsets, scrollRef],
);
const selectIndex = React.useCallback(
(index: number, preserveExpanded = false) => {
if (messages.length === 0) {
setCursor(null);
return;
}
const boundedIndex = clampIndex(index, messages.length);
const message = messages[boundedIndex];
if (!message) {
setCursor(null);
return;
}
setCursor(prev => ({
id: message.id,
expanded: preserveExpanded && prev?.id === message.id ? prev.expanded : false,
}));
},
[messages, setCursor],
);
React.useEffect(() => {
if (!cursor) {
return;
}
if (!messages.some(message => message.id === cursor.id)) {
setCursor(null);
}
}, [cursor, messages, setCursor]);
React.useEffect(() => {
if (!scrollChrome) {
return;
}
if (!trackStickyPrompt) {
scrollChrome.setStickyPrompt(null);
return;
}
if (scrollTop <= 0) {
scrollChrome.setStickyPrompt(null);
return;
}
for (let index = Math.max(0, startIndex - 1); index >= 0; index -= 1) {
const message = messages[index];
if (!message) {
continue;
}
const promptText = createStickyPromptText(message);
if (!promptText) {
continue;
}
scrollChrome.setStickyPrompt({
text: promptText,
scrollTo: () => {
scrollToIndex(index);
selectIndex(index, true);
},
});
return;
}
scrollChrome.setStickyPrompt(null);
}, [
messages,
scrollChrome,
scrollToIndex,
scrollTop,
selectIndex,
startIndex,
trackStickyPrompt,
]);
const recomputeSearchMatches = React.useCallback(
(query: string) => {
const normalized = query.trim().toLowerCase();
searchQueryRef.current = normalized;
if (!normalized) {
searchMatchesRef.current = [];
currentSearchMatchRef.current = -1;
onSearchMatchesChange?.(0, 0);
return;
}
const matches: number[] = [];
loweredSearchTexts.forEach((text, index) => {
if (text.includes(normalized)) {
matches.push(index);
}
});
searchMatchesRef.current = matches;
currentSearchMatchRef.current = matches.length > 0 ? 0 : -1;
onSearchMatchesChange?.(
matches.length,
matches.length > 0 ? 1 : 0,
);
if (matches.length > 0) {
const matchIndex = matches[0]!;
scrollToIndex(matchIndex);
selectIndex(matchIndex, true);
}
},
[loweredSearchTexts, onSearchMatchesChange, scrollToIndex, selectIndex],
);
React.useImperativeHandle(
jumpRef,
() => ({
jumpToIndex(index: number) {
scrollToIndex(index);
selectIndex(index, true);
},
setSearchQuery(query: string) {
recomputeSearchMatches(query);
},
nextMatch() {
const matches = searchMatchesRef.current;
if (matches.length === 0) {
return;
}
currentSearchMatchRef.current =
(currentSearchMatchRef.current + 1) % matches.length;
const matchIndex =
matches[currentSearchMatchRef.current] ?? matches[0]!;
onSearchMatchesChange?.(
matches.length,
currentSearchMatchRef.current + 1,
);
scrollToIndex(matchIndex);
selectIndex(matchIndex, true);
},
prevMatch() {
const matches = searchMatchesRef.current;
if (matches.length === 0) {
return;
}
currentSearchMatchRef.current =
(currentSearchMatchRef.current - 1 + matches.length) %
matches.length;
const matchIndex =
matches[currentSearchMatchRef.current] ??
matches[matches.length - 1]!;
onSearchMatchesChange?.(
matches.length,
currentSearchMatchRef.current + 1,
);
scrollToIndex(matchIndex);
selectIndex(matchIndex, true);
},
setAnchor() {
searchAnchorRef.current =
scrollRef?.current?.getScrollTop() ?? null;
},
async warmSearchIndex() {
const start = performance.now();
loweredSearchTexts.length;
return Math.round(performance.now() - start);
},
disarmSearch() {
currentSearchMatchRef.current = -1;
if (searchQueryRef.current.length > 0) {
onSearchMatchesChange?.(
searchMatchesRef.current.length,
0,
);
}
},
}),
[
jumpRef,
loweredSearchTexts.length,
onSearchMatchesChange,
recomputeSearchMatches,
scrollRef,
scrollToIndex,
selectIndex,
],
);
React.useImperativeHandle(
cursorNavRef,
() => ({
enterCursor() {
const candidateIndexes =
scrollTop > 0
? [
...Array.from(
{ length: Math.max(0, endIndex - startIndex) },
(_, offset) => startIndex + offset,
),
...Array.from(
{ length: startIndex },
(_, offset) => startIndex - offset - 1,
),
]
: Array.from(
{ length: messages.length },
(_, offset) => messages.length - offset - 1,
);
for (const index of candidateIndexes) {
if (isItemNavigable(messages[index]!)) {
selectIndex(index, true);
scrollToIndex(index);
return;
}
}
},
navigatePrev() {
const currentIndex = cursor
? messages.findIndex(message => message.id === cursor.id)
: messages.length;
for (let index = currentIndex - 1; index >= 0; index -= 1) {
if (!isItemNavigable(messages[index]!)) {
continue;
}
selectIndex(index);
scrollToIndex(index);
return;
}
},
navigateNext() {
const currentIndex = cursor
? messages.findIndex(message => message.id === cursor.id)
: -1;
for (let index = currentIndex + 1; index < messages.length; index += 1) {
if (!isItemNavigable(messages[index]!)) {
continue;
}
selectIndex(index);
scrollToIndex(index);
return;
}
},
navigateTop() {
for (let index = 0; index < messages.length; index += 1) {
if (!isItemNavigable(messages[index]!)) {
continue;
}
selectIndex(index);
scrollToIndex(index);
return;
}
},
navigateBottom() {
for (let index = messages.length - 1; index >= 0; index -= 1) {
if (!isItemNavigable(messages[index]!)) {
continue;
}
selectIndex(index);
scrollToIndex(index);
return;
}
},
getSelected() {
return cursor
? messages.find(message => message.id === cursor.id) ?? null
: null;
},
}),
[
cursor,
cursorNavRef,
endIndex,
isItemNavigable,
messages,
scrollTop,
scrollToIndex,
selectIndex,
startIndex,
],
);
return (
<InVirtualListContext.Provider value={true}>
<Box flexDirection="column">
<VerticalSpacer rows={topSpacerRows} />
{visibleMessages.map((message, index) => {
const absoluteIndex = startIndex + index;
const isSelected = cursor?.id === message.id;
return (
<MessageActionsSelectedContext.Provider
key={itemKey(message)}
value={Boolean(isSelected)}
>
{renderItem(message, absoluteIndex)}
</MessageActionsSelectedContext.Provider>
);
})}
<VerticalSpacer rows={bottomSpacerRows} />
</Box>
</InVirtualListContext.Provider>
);
}

View file

@ -0,0 +1,22 @@
import React from "react";
import { Box } from "ink";
import { getColor } from "./theme.js";
type Props = {
children: React.ReactNode;
};
export function Pane({
children,
}: Props): React.ReactElement {
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor={getColor("border")}
paddingX={1}
>
{children}
</Box>
);
}

View file

@ -0,0 +1,32 @@
import React from "react";
import { Box, type DOMElement } from "ink";
import { getColor, getSpacing, type ColorToken, type ThemeSpacing } from "./theme.js";
type InkBoxProps = React.ComponentProps<typeof Box>;
type ThemedBoxProps = Omit<InkBoxProps, "borderColor" | "paddingX" | "paddingY" | "marginTop" | "marginBottom"> & {
borderToken?: ColorToken;
paddingSize?: keyof ThemeSpacing;
marginTopSize?: keyof ThemeSpacing;
marginBottomSize?: keyof ThemeSpacing;
ref?: React.Ref<DOMElement>;
};
export function ThemedBox({
borderToken,
paddingSize,
marginTopSize,
marginBottomSize,
children,
...rest
}: ThemedBoxProps): React.ReactElement {
const props: InkBoxProps = {
...rest,
...(borderToken ? { borderColor: getColor(borderToken) } : {}),
...(paddingSize ? { paddingX: getSpacing(paddingSize) } : {}),
...(marginTopSize ? { marginTop: getSpacing(marginTopSize) } : {}),
...(marginBottomSize ? { marginBottom: getSpacing(marginBottomSize) } : {}),
};
return <Box {...props}>{children}</Box>;
}

View file

@ -0,0 +1,25 @@
import React from "react";
import { Text } from "ink";
import { getColor, type ColorToken } from "./theme.js";
type InkTextProps = React.ComponentProps<typeof Text>;
type ThemedTextProps = Omit<InkTextProps, "color"> & {
colorToken?: ColorToken;
color?: string;
};
export function ThemedText({
colorToken,
color,
children,
...rest
}: ThemedTextProps): React.ReactElement {
const resolvedColor = colorToken ? getColor(colorToken) : color;
return (
<Text color={resolvedColor} {...rest}>
{children}
</Text>
);
}

View file

@ -0,0 +1,15 @@
export {
type ColorToken,
type Theme,
type ThemeColors,
type ThemeSpacing,
THEMES,
getTheme,
setTheme,
getColor,
getSpacing,
} from "./theme.js";
export { ThemedBox } from "./ThemedBox.js";
export { ThemedText } from "./ThemedText.js";
export { Pane } from "./Pane.js";

View file

@ -0,0 +1,143 @@
export type ColorToken =
| "primary"
| "secondary"
| "accent"
| "success"
| "warning"
| "error"
| "muted"
| "text"
| "textDim"
| "textInverse"
| "border"
| "borderFocused"
| "borderDim"
| "bg"
| "bgHighlight"
| "bgOverlay"
| "userMessage"
| "assistantMessage"
| "toolMessage"
| "systemMessage"
| "statusMessage"
| "errorMessage"
| "spinner"
| "permissionBorder"
| "elicitationBorder"
| "inputBorder"
| "inputBorderFocused"
| "inputBorderDisabled";
export type ThemeColors = Record<ColorToken, string>;
export type ThemeSpacing = {
none: 0;
xs: 1;
sm: 1;
md: 2;
lg: 3;
xl: 4;
};
export type Theme = {
name: string;
colors: ThemeColors;
spacing: ThemeSpacing;
};
const DARK_COLORS: ThemeColors = {
primary: "cyan",
secondary: "blue",
accent: "magenta",
success: "green",
warning: "yellow",
error: "red",
muted: "gray",
text: "white",
textDim: "gray",
textInverse: "black",
border: "gray",
borderFocused: "cyan",
borderDim: "gray",
bg: "",
bgHighlight: "gray",
bgOverlay: "",
userMessage: "cyan",
assistantMessage: "green",
toolMessage: "magenta",
systemMessage: "gray",
statusMessage: "blue",
errorMessage: "red",
spinner: "yellow",
permissionBorder: "yellow",
elicitationBorder: "magenta",
inputBorder: "green",
inputBorderFocused: "green",
inputBorderDisabled: "gray",
};
const LIGHT_COLORS: ThemeColors = {
primary: "blue",
secondary: "cyan",
accent: "magenta",
success: "green",
warning: "yellow",
error: "red",
muted: "gray",
text: "black",
textDim: "gray",
textInverse: "white",
border: "gray",
borderFocused: "blue",
borderDim: "gray",
bg: "",
bgHighlight: "gray",
bgOverlay: "",
userMessage: "blue",
assistantMessage: "green",
toolMessage: "magenta",
systemMessage: "gray",
statusMessage: "cyan",
errorMessage: "red",
spinner: "yellow",
permissionBorder: "yellow",
elicitationBorder: "magenta",
inputBorder: "green",
inputBorderFocused: "blue",
inputBorderDisabled: "gray",
};
const SPACING: ThemeSpacing = {
none: 0,
xs: 1,
sm: 1,
md: 2,
lg: 3,
xl: 4,
};
export const THEMES: Record<string, Theme> = {
dark: { name: "dark", colors: DARK_COLORS, spacing: SPACING },
light: { name: "light", colors: LIGHT_COLORS, spacing: SPACING },
};
let activeTheme: Theme = THEMES.dark!;
export function getTheme(): Theme {
return activeTheme;
}
export function setTheme(name: string): void {
const theme = THEMES[name];
if (theme) {
activeTheme = theme;
}
}
export function getColor(token: ColorToken): string {
return activeTheme.colors[token];
}
export function getSpacing(size: keyof ThemeSpacing): number {
return activeTheme.spacing[size];
}

View file

@ -0,0 +1,125 @@
import React from "react";
import { Box, Text } from "ink";
import { getColor } from "../design-system/theme.js";
export type DiffLine = {
type: "add" | "remove" | "context" | "header";
content: string;
oldLineNo?: number;
newLineNo?: number;
};
export type DiffHunk = {
header: string;
lines: DiffLine[];
};
export type DiffFile = {
path: string;
hunks: DiffHunk[];
isBinary?: boolean;
isNew?: boolean;
isDeleted?: boolean;
isRenamed?: { from: string; to: string };
};
type DiffViewProps = {
file: DiffFile;
maxLines?: number;
collapsed?: boolean;
};
function lineTypeColor(type: DiffLine["type"]): string {
switch (type) {
case "add":
return getColor("success");
case "remove":
return getColor("error");
case "header":
return getColor("primary");
case "context":
default:
return getColor("text");
}
}
function linePrefix(type: DiffLine["type"]): string {
switch (type) {
case "add":
return "+";
case "remove":
return "-";
case "header":
return "@";
case "context":
default:
return " ";
}
}
function formatLineNo(n: number | undefined, width: number): string {
if (n === undefined) return " ".repeat(width);
return String(n).padStart(width);
}
export function DiffView({
file,
maxLines,
collapsed,
}: DiffViewProps): React.ReactElement {
const allLines = file.hunks.flatMap(hunk => [
{ type: "header" as const, content: hunk.header },
...hunk.lines,
]);
const visibleLines = maxLines ? allLines.slice(0, maxLines) : allLines;
const truncated = maxLines !== undefined && allLines.length > maxLines;
const lineNoWidth = Math.max(
3,
String(Math.max(...allLines.map(l => l.newLineNo ?? l.oldLineNo ?? 0))).length,
);
const label = file.isNew
? "(new file)"
: file.isDeleted
? "(deleted)"
: file.isRenamed
? `(renamed from ${file.isRenamed.from})`
: "";
return (
<Box flexDirection="column">
<Box>
<Text color={getColor("primary")} bold>
{collapsed ? "▸ " : "▾ "}
{file.path}
</Text>
{label ? <Text color={getColor("textDim")}> {label}</Text> : null}
</Box>
{file.isBinary ? (
<Text color={getColor("textDim")}> Binary file</Text>
) : collapsed ? null : (
<Box flexDirection="column" marginLeft={1}>
{visibleLines.map((line, i) => (
<Box key={i}>
<Text color={getColor("textDim")}>
{formatLineNo(line.oldLineNo, lineNoWidth)}{" "}
{formatLineNo(line.newLineNo, lineNoWidth)}{" "}
</Text>
<Text color={lineTypeColor(line.type)}>
{linePrefix(line.type)} {line.content}
</Text>
</Box>
))}
{truncated ? (
<Text color={getColor("textDim")}>
{allLines.length - visibleLines.length} more lines
</Text>
) : null}
</Box>
)}
</Box>
);
}

View file

@ -0,0 +1,6 @@
export {
DiffView,
type DiffFile,
type DiffHunk,
type DiffLine,
} from "./DiffView.js";

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