mirror of
https://github.com/HKUDS/OpenSpace.git
synced 2026-08-28 05:15:00 +00:00
v2 initial commit
This commit is contained in:
commit
fadc4bccab
1309 changed files with 315833 additions and 0 deletions
141
.gitignore
vendored
Normal file
141
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
# 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/
|
||||||
|
!tests/
|
||||||
|
tests/*
|
||||||
|
!tests/agents/
|
||||||
|
tests/agents/*
|
||||||
|
!tests/agents/turns/
|
||||||
|
tests/agents/turns/*
|
||||||
|
!tests/agents/turns/test_bench_checker_guard.py
|
||||||
|
!tests/agents/turns/test_model_call_controller_bench_guards.py
|
||||||
|
!tests/grounding/
|
||||||
|
tests/grounding/*
|
||||||
|
!tests/grounding/core/
|
||||||
|
tests/grounding/core/*
|
||||||
|
!tests/grounding/core/permissions/
|
||||||
|
tests/grounding/core/permissions/*
|
||||||
|
!tests/grounding/core/permissions/test_loader_runtime_modes.py
|
||||||
|
!tests/runtime/
|
||||||
|
tests/runtime/*
|
||||||
|
!tests/runtime/test_headless_permission_context.py
|
||||||
|
!tests/benchmarks/
|
||||||
|
tests/benchmarks/*
|
||||||
|
!tests/benchmarks/terminal_bench/
|
||||||
|
tests/benchmarks/terminal_bench/*
|
||||||
|
!tests/benchmarks/terminal_bench/test_openspace_harbor_agent_config.py
|
||||||
|
!tests/skill_engine/
|
||||||
|
tests/skill_engine/*
|
||||||
|
!tests/skill_engine/test_skill_trust_lifecycle.py
|
||||||
|
!tests/skill_engine/test_evolver_length_recovery.py
|
||||||
|
!tests/skill_engine/test_evolution_retry_idempotency.py
|
||||||
|
!tests/skill_engine/test_analyzer_length_recovery.py
|
||||||
|
!tests/skill_engine/decision/
|
||||||
|
tests/skill_engine/decision/*
|
||||||
|
!tests/skill_engine/decision/test_analysis_adapter.py
|
||||||
|
!tests/skill_engine/evolution/
|
||||||
|
tests/skill_engine/evolution/*
|
||||||
|
!tests/skill_engine/evolution/test_admission_capture.py
|
||||||
|
!tests/skill_engine/evolution/test_capture_contract_pipeline.py
|
||||||
|
!tests/skill_engine/evolution/test_capture_semantic_reviewer.py
|
||||||
|
!tests/cloud/
|
||||||
|
tests/cloud/*
|
||||||
|
!tests/cloud/test_upload_trust.py
|
||||||
|
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/
|
||||||
|
|
||||||
|
# Terminal-Bench benchmark harness is not part of this release
|
||||||
|
benchmarks/terminal_bench/
|
||||||
|
!benchmarks/terminal_bench/
|
||||||
|
!benchmarks/terminal_bench/*.py
|
||||||
|
!benchmarks/terminal_bench/**/*.py
|
||||||
|
benchmarks/terminal_bench/runs/
|
||||||
|
benchmarks/terminal_bench/datasets/
|
||||||
|
benchmarks/terminal_bench/logs/
|
||||||
|
|
||||||
|
# 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/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
5
COMMUNICATION.md
Normal 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
21
LICENSE
Normal 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
17
MANIFEST.in
Normal 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
|
||||||
684
README.md
Normal file
684
README.md
Normal file
|
|
@ -0,0 +1,684 @@
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
<picture>
|
||||||
|
<img src="assets/logo_v2.png" width="280px" style="border: none; box-shadow: none;" alt="OpenSpace Logo">
|
||||||
|
</picture>
|
||||||
|
|
||||||
|
## OpenSpace: The Quality-First Skill Hub for AI Agents
|
||||||
|
|
||||||
|
| 📊 **Real-Task Validated** | 🌐 **Hierarchical Skill Hub** | 🧬 **Evidence-Driven Evolution** | 🛠️ **End2End Quality Records** |
|
||||||
|
|
||||||
|
[](https://modelcontextprotocol.io/)
|
||||||
|
[](https://www.python.org/)
|
||||||
|
[](https://opensource.org/licenses/MIT/)
|
||||||
|
[](./COMMUNICATION.md)
|
||||||
|
[](./COMMUNICATION.md)
|
||||||
|
[](./README_zh.md)
|
||||||
|
[](https://github.com/HKUDS/OpenSpace/blob/v1/README.md)
|
||||||
|
|
||||||
|
**Your Universal Skill Hub for All AI Agents** — Claude Code, Codex, OpenClaw, Hermès, nanobot.
|
||||||
|
|
||||||
|
<img src="assets/cli-typing.gif" width="500px" alt="openspace --query your task">
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Why OpenSpace?
|
||||||
|
Your agent can already run tasks. But can it remember which skills worked? Can it stop repeating the same mistakes? Can your team share what it learned?
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td width="33.33%" align="center"><img src="assets/feat_1.png" alt="One Skill Hub for every agent" width="100%"></td>
|
||||||
|
<td width="33.33%" align="center"><img src="assets/feat_2.png" alt="A private skill platform your org actually owns" width="100%"></td>
|
||||||
|
<td width="33.33%" align="center"><img src="assets/feat_3.png" alt="Agents that get better with every run" width="100%"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td width="33.33%" valign="top">
|
||||||
|
<p align="center"><strong>🌐 One Skill Hub for every agent</strong></p>
|
||||||
|
<p>Whether you run OpenClaw, nanobot, Claude Code, Codex, or Cursor, OpenSpace gives all of them a shared place to browse, import, and reuse skills. Stop rebuilding the same experience from scratch in every tool.</p>
|
||||||
|
</td>
|
||||||
|
<td width="33.33%" valign="top">
|
||||||
|
<p align="center"><strong>🔒 A private skill platform your org actually owns</strong></p>
|
||||||
|
<p>Deploy OpenSpace inside your own infrastructure. Your workflows stay internal, your data never leaves, and every skill your agents learn becomes a compounding asset — not a black box on someone else's server.</p>
|
||||||
|
</td>
|
||||||
|
<td width="33.33%" valign="top">
|
||||||
|
<p align="center"><strong>📈 Agents that get better with every run</strong></p>
|
||||||
|
<p>OpenSpace tracks real task outcomes to evolve skills that work, retire ones that do not, and distill experience into leaner, sharper prompts — so your agent improves over time and spends fewer tokens getting there.</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📢 News
|
||||||
|
|
||||||
|
- **2026-07-17** 🚀 **OpenSpace v2 is released**: v2 turns OpenSpace into a quality-first Skill Hub with package-based skill browsing, skill quality summaries, task-trace uploads, and a refreshed dashboard / TUI experience.
|
||||||
|
|
||||||
|
- **2026-07-04** 📊 **Skill quality summaries now visible while browsing v2 skills**: package and skill detail views show usage-quality summaries; public lineage pages display redacted placeholders for unavailable content.
|
||||||
|
|
||||||
|
- **2026-07-03** 🔎 **Package skill search and task-trace uploads are now first-class v2 flows**: package pages search skills directly, and task traces can be validated, stored, and uploaded idempotently as quality evidence.
|
||||||
|
|
||||||
|
- **2026-06-25** 🌐 **The v2 cloud path became more stable for public browsing and private skill access**: public pages, private skill endpoints, frontend / backend routes, and TLS access are now checked together.
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Earlier news</summary>
|
||||||
|
|
||||||
|
- **2026-06-19** 🌐 **Public v2 pages can be read without login**: anonymous visitors can browse public skills, existing users gained an agent bootstrap path, and search / recall services were restored.
|
||||||
|
|
||||||
|
- **2026-06-18** 🧭 **The v2 cloud experience became more complete**: package, group, profile, and agent pages were assembled into a cleaner package-browser flow with a more structured import path.
|
||||||
|
|
||||||
|
- **2026-06-03** 🪟 **Windows communication gateway startup became more reliable**: process liveness checks received a follow-up fix for users running message adapters on Windows.
|
||||||
|
|
||||||
|
- **2026-06-02** 🚀 **Release v2 introduced the new local experience**: the branch added the v2 README, dashboard, TUI, runtime services, sandboxing, memory, scheduler, skill evidence, evolution, triggers, and related assets.
|
||||||
|
|
||||||
|
- **2026-05-27** 🪟 **Communication gateway Windows compatibility improved**: gateway runtime PID checks switched to Windows API handling on Windows while keeping the Unix fallback, fixing gateway startup failures on Windows.
|
||||||
|
|
||||||
|
- **2026-05-14** 🧭 **Skill libraries, group detail, and lineage history expanded**: users gained owned-skill library pages, shared-skill views inside groups, and retained lineage history for inactive relationships.
|
||||||
|
|
||||||
|
- **2026-05-13** 📈 **Package and skill detail pages became safer to inspect**: private data stays hidden when access is unavailable, and task-step records make skill/package views more useful for quality analysis.
|
||||||
|
|
||||||
|
- **2026-05-13** ⏱️ **Long shell work became more reliable**: timeouts now clean up subprocess trees, post-task analysis is bounded, and skill-search cache writes are safer.
|
||||||
|
|
||||||
|
- **2026-05-10** 📦 **V1 skills can map into v2 identity records**: package skill search respects lineage visibility, and individual skill bundles can be pulled directly.
|
||||||
|
|
||||||
|
- **2026-05-09** 👥 **V2 group sharing shipped**: group-scoped skill sharing became available while v1 and v2 sharing paths stay separated.
|
||||||
|
|
||||||
|
- **2026-05-01** 🏗️ **Legacy skill collections became easier to move into the v2 hierarchy**: package migration and synthesis tooling added deterministic sampling, safer agent loops, and resume checks.
|
||||||
|
|
||||||
|
- **2026-04-29** 📡 **Search and package pull started producing quality records**: v2 added telemetry-backed search, package pull, skill-use sessions, and evolution telemetry for later quality summaries.
|
||||||
|
|
||||||
|
- **2026-04-22** 🛡️ **Upload, share, and promote flows became safer to retry**: duplicate and replay handling was hardened so repeated requests behave predictably.
|
||||||
|
|
||||||
|
- **2026-04-20** 🔎 **V2 search gained lexical recall and semantic reranking**: package and skill discovery improved beyond exact text matching.
|
||||||
|
|
||||||
|
- **2026-04-18** 🛡️ **Sharing and promote-to-public became more predictable**: group / public visibility changes gained idempotent behavior and safer access checks.
|
||||||
|
|
||||||
|
- **2026-04-18** ⚡ **Local skill search became much faster after warm-up**: `search_skills` now reuses the SkillRanker embedding cache and refreshes embeddings when skill text changes.
|
||||||
|
|
||||||
|
- **2026-04-17** 🔄 **Shared package indexes became more reliable after uploads and sharing changes**: background rebuild and recovery paths now keep package search data in sync.
|
||||||
|
|
||||||
|
- **2026-04-16** 🧬 **Evolution candidate status became trackable**: OpenSpace can record candidate processing state, and macOS window / screenshot features no longer get disabled just because `atomacos` is unavailable.
|
||||||
|
|
||||||
|
- **2026-04-10** 🧩 **CAPTURED skill placement was corrected**: CAPTURED skills now write back to the correct host-agent skill directory.
|
||||||
|
|
||||||
|
- **2026-04-09** 💬 **WhatsApp and Feishu adapters shipped**: OpenSpace added session management, attachment caching, allowlists, and private-safe cloud upload compatibility for external message workflows.
|
||||||
|
|
||||||
|
- **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!
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The Problem with Today's AI Agents
|
||||||
|
|
||||||
|
Today's AI agents — OpenClaw, nanobot, Claude Code, Codex, Cursor, and more — are remarkably capable. But beneath the surface, they share a critical blind spot: none of them know which skills actually hold up in the real world.
|
||||||
|
|
||||||
|
Think of it like a recipe book that keeps growing — but nobody has ever cooked from it, so no one knows which recipes actually taste good.
|
||||||
|
|
||||||
|
- **❌ Skills accumulate without quality signals** — The more you use an agent, the more skills pile up. But there is no way to tell a skill that reliably delivers from one that quietly fails. They all sit in the same folder, looking equally trustworthy.
|
||||||
|
|
||||||
|
- **❌ Agents keep repeating the same mistakes** — Once a skill gets picked, the agent keeps reaching for it — even after it starts failing. Without a feedback loop, the agent has no way to learn from bad outcomes. It just tries again.
|
||||||
|
|
||||||
|
- **❌ Updating skills is a guessing game** — Change too much and you break things that were working. Change too little and the agent falls behind. There is no principled way to know what to improve, when, or why.
|
||||||
|
|
||||||
|
- **❌ Sharing a skill means asking for blind trust** — A skill shared online may look polished. But where did it come from? Has it changed? Has anyone actually finished a real task with it? Today, there is no easy way to know.
|
||||||
|
|
||||||
|
## 🎯 What is OpenSpace?
|
||||||
|
|
||||||
|
**🚀 OpenSpace is a quality-first Skill Hub where real tasks teach agents which skills to trust, reuse, improve, and share.**
|
||||||
|
|
||||||
|
https://github.com/user-attachments/assets/1c6b1b44-b207-491b-ad23-0f0591c17e0a
|
||||||
|
|
||||||
|
OpenSpace plugs into your agent as skills.
|
||||||
|
|
||||||
|
- **v1** helped agents learn, evolve, and share experience.
|
||||||
|
|
||||||
|
- **v2** adds the missing quality layer: every skill is judged by real task results, improved through controlled evolution, and shared with clear context — not just uploaded and forgotten.
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<img src="assets/skillwiki.png" width="760" alt="OpenSpace Skill Wiki package tree and skill search visualization">
|
||||||
|
<br />
|
||||||
|
<sub>Skill Wiki turns shared skills into a searchable package tree with lineage and quality context.</sub>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
OpenSpace v2 gives agents four practical abilities:
|
||||||
|
|
||||||
|
### 📊 Skill Quality from Real Tasks
|
||||||
|
|
||||||
|
Stop guessing. Know which skills actually work.
|
||||||
|
|
||||||
|
- **✅ Task-result quality** — Every skill run is tracked: was it selected, applied, completed, or did it fall back? Over time, the pattern tells the truth.
|
||||||
|
- **✅ Tool reliability** — When a tool fails, slows down, or becomes risky, every skill that depends on it gets flagged — automatically.
|
||||||
|
- **✅ Quality-aware reuse** — A skill that consistently finishes real work is treated differently from one that keeps falling short. Your agent stops guessing.
|
||||||
|
- **✅ Clear evidence** — Instead of trusting a skill's description, users can inspect what actually happened across real runs.
|
||||||
|
|
||||||
|
**A skill earns its place by working in the real world — not by looking good in a file.**
|
||||||
|
|
||||||
|
### 🧬 Controlled Skill Evolution
|
||||||
|
|
||||||
|
Agents need to improve. But improvement without control is just chaos.
|
||||||
|
- ✅ **Evidence-driven updates** — Real task evidence decides when a skill should be fixed, derived, or captured.
|
||||||
|
- ✅ **Provisional first** — New evolved skills remain reusable but provisional until real cross-task success promotes them to trusted.
|
||||||
|
- ✅ **Independent trust** — Trust and availability are separate; a skill can be provisional or trusted while operators independently enable or disable it.
|
||||||
|
- ✅ **Validated skills** — A skill is checked before a new version replaces the old one.
|
||||||
|
- ✅ **Version history** — Users can see how a skill changed over time.
|
||||||
|
|
||||||
|
**Agents should adapt to the real world, but every change needs control.**
|
||||||
|
|
||||||
|
### 🌐 Local-First Skill Hub
|
||||||
|
|
||||||
|
Your skills run locally. Your data never has to leave.
|
||||||
|
|
||||||
|
- ✅ **Local-first workflow** — Your agent can run, search, and evolve skills locally.
|
||||||
|
- ✅ **Package organization** — Cloud skills are grouped by package so people can browse and review them.
|
||||||
|
- ✅ **Explicit import** — Cloud skills are imported into a local skill folder before reuse.
|
||||||
|
- ✅ **Reviewable sharing** — Shared skills carry context such as package, visibility, history, and quality signals.
|
||||||
|
|
||||||
|
**The cloud is for skill discovery. Your machine is for agent execution. The line never blurs.**
|
||||||
|
|
||||||
|
### 🛠️ Agent Harness with Quality Records
|
||||||
|
Run the agent in a way that leaves useful evidence.
|
||||||
|
- ✅ **Recoverable sessions** — Long tasks can keep their task history, tool results, and files.
|
||||||
|
- ✅ **Permission-aware tools** — Tool calls pass through validation, permissions, and sandboxing.
|
||||||
|
- ✅ **Quality records** — Executions produce the evidence used for quality judgment and evolution.
|
||||||
|
- ✅ **One runtime boundary** — CLI, Python API, MCP, gateway, and dashboard share the same execution model.
|
||||||
|
|
||||||
|
**OpenSpace does not just run your tasks — it turns every run into evidence, and every piece of evidence into a skill worth trusting.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### The Difference
|
||||||
|
|
||||||
|
**❌ Current Agents**
|
||||||
|
- Skills pile up with no signal for which ones still hold up.
|
||||||
|
- Failures repeat because nothing marks a bad experience as one to avoid.
|
||||||
|
- Self-improvement is either absent or uncontrolled — noise either way.
|
||||||
|
- Shared skills ask for blind trust, with no quality tied to real results.
|
||||||
|
|
||||||
|
**✅ OpenSpace v1**
|
||||||
|
- Gives agents a persistent skill memory that carries across tasks and sessions.
|
||||||
|
- Learns from successful workflows and failed executions — not just the happy path.
|
||||||
|
- Evolves skills through structured FIX, DERIVED, and CAPTURED updates.
|
||||||
|
- Shares hard-won experience so one agent's lessons can benefit another.
|
||||||
|
|
||||||
|
**✅ OpenSpace v2**
|
||||||
|
- Keeps the v1 learning loop, and makes quality the signal that drives everything.
|
||||||
|
- Judges every skill by what actually happened: selected, applied, completed, or fell back.
|
||||||
|
- Evolves skills only when evidence demands it — with validation, version history, and full control.
|
||||||
|
- Organizes cloud skills by package for meaningful browsing, then imports them locally before any reuse.
|
||||||
|
- Runs agents in a harness that captures the evidence quality judgment and skill evolution both depend on.
|
||||||
|
|
||||||
|
## 📋 Table of Contents
|
||||||
|
|
||||||
|
- [⚡ Quick Start](#-quick-start)
|
||||||
|
- [🤖 Path A: For Your Agent](#-path-a-for-your-agent)
|
||||||
|
- [👤 Path B: Command Line](#-path-b-command-line)
|
||||||
|
- [📊 Local Dashboard](#-local-dashboard)
|
||||||
|
- [🏗️ Framework](#framework)
|
||||||
|
- [📊 Skill Quality Layer](#-skill-quality-layer)
|
||||||
|
- [🧬 Controlled Skill Evolution](#-controlled-skill-evolution)
|
||||||
|
- [🌐 Local-First Skill Hub](#-local-first-skill-hub)
|
||||||
|
- [🛠️ Agent Harness with Quality Records](#️-agent-harness-with-quality-records)
|
||||||
|
<!-- - [🔧 Advanced Configuration](#-advanced-configuration) -->
|
||||||
|
- [📖 Code Structure](#-code-structure)
|
||||||
|
- [🔗 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 --no-cone '/*' '!/assets/'
|
||||||
|
> pip install -e .
|
||||||
|
> ```
|
||||||
|
|
||||||
|
**Choose your path:**
|
||||||
|
- **[Path A](#-path-a-for-your-agent)** — Plug OpenSpace into your agent
|
||||||
|
- **[Path B](#-path-b-command-line)** — Use OpenSpace directly from the command line
|
||||||
|
|
||||||
|
### 🤖 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.
|
||||||
|
|
||||||
|
**For your agent**
|
||||||
|
|
||||||
|
Open your coding agent and paste:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Install OpenSpace for this host agent.
|
||||||
|
|
||||||
|
If an OpenSpace repo is already open, use its current repository root as
|
||||||
|
OPENSPACE_WORKSPACE. Otherwise, clone it first:
|
||||||
|
`git clone https://github.com/HKUDS/OpenSpace.git && cd OpenSpace`
|
||||||
|
|
||||||
|
First read:
|
||||||
|
- README.md -> Quick Start -> Path A: For Your Agent
|
||||||
|
- openspace/host_skills/README.md -> exact setup for this host
|
||||||
|
- openspace/.env.example only if model or cloud credentials are needed
|
||||||
|
|
||||||
|
Then:
|
||||||
|
1. Verify a Python 3.12+ interpreter is available. If `openspace-mcp --help`
|
||||||
|
is unavailable, install OpenSpace from this repo with that interpreter:
|
||||||
|
`python -m pip install -e .`
|
||||||
|
2. Detect this host agent's MCP config file/format and local skill directory.
|
||||||
|
Preserve existing config and unrelated MCP servers.
|
||||||
|
3. Configure an MCP server named `openspace`. Prefer stdio for local use:
|
||||||
|
`command: openspace-mcp`. Use streamable HTTP only if this host cannot use
|
||||||
|
stdio or needs a standalone/remote server.
|
||||||
|
4. Set `OPENSPACE_WORKSPACE` to the absolute repo root and
|
||||||
|
`OPENSPACE_HOST_SKILL_DIRS` to the host agent's skill directory.
|
||||||
|
5. Copy `openspace/host_skills/delegate-task` and
|
||||||
|
`openspace/host_skills/skill-discovery` into the host agent's skill directory.
|
||||||
|
6. If cloud access is required, use `openspace-cloud-auth bootstrap-agent-key`.
|
||||||
|
Do not ask me to paste secrets into chat; stop if a required credential or
|
||||||
|
email is missing.
|
||||||
|
7. Reload or restart the host agent if its MCP/skill system requires it.
|
||||||
|
|
||||||
|
Do not report success until `openspace-mcp --help` works, the MCP client can see
|
||||||
|
OpenSpace tools, a lightweight local skill search works, and long `execute_task`
|
||||||
|
calls have a timeout of at least 600 seconds. In your final report, include the
|
||||||
|
MCP config path, skill directory, chosen transport, and verification results. If
|
||||||
|
any path, config format, Python version, credential, MCP transport, or skill
|
||||||
|
directory is missing, stop and tell me exactly what is missing.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Setup steps (manual or agent-assisted)**
|
||||||
|
|
||||||
|
**① Add OpenSpace to your host 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 [Skills](#skills).
|
||||||
|
|
||||||
|
> [!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: Command Line
|
||||||
|
|
||||||
|
Use OpenSpace directly from the command line — 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 command-line mode
|
||||||
|
openspace
|
||||||
|
|
||||||
|
# Execute task
|
||||||
|
openspace --model "anthropic/claude-sonnet-4-5" --query "Create a monitoring dashboard for my Docker containers"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Skills
|
||||||
|
|
||||||
|
Add project skills under `.openspace/skills/<skill-name>/`. Each skill is a directory containing a `SKILL.md`; optional helper files can live alongside it:
|
||||||
|
|
||||||
|
```text
|
||||||
|
.openspace/
|
||||||
|
└── skills/
|
||||||
|
├── my-skill/
|
||||||
|
│ └── SKILL.md
|
||||||
|
└── another-skill/
|
||||||
|
├── SKILL.md
|
||||||
|
└── helper.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Discovery, IDs, and safety</summary>
|
||||||
|
|
||||||
|
OpenSpace discovers skills from `OPENSPACE_HOST_SKILL_DIRS`, configured `skills.skill_dirs`, project roots such as `.openspace/skills`, user roots such as `~/.openspace/skills`, and finally bundled OpenSpace skills in `openspace/skills`.
|
||||||
|
|
||||||
|
Each discovered skill has a `.skill_id` sidecar for stable tracking. New project or user skills can omit it; OpenSpace creates one on first discovery. Keep `.skill_id` when you want a copied skill to remain the same logical skill, and remove it before first discovery when you are creating an independent skill. Cloud upload requires the matching local SkillStore record to be `trusted`; both public and private uploads fail closed for provisional or unknown records. The local trust state is not sent to the cloud, and `.skill_id` is skipped as a regular uploaded file.
|
||||||
|
|
||||||
|
All discovered skills pass `check_skill_safety` before loading. Skills with dangerous patterns, such as prompt injection or credential exfiltration, are blocked and logged.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
**Cloud CLI** — manage skills from the command line:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openspace-download-skill <skill_id> # download a skill from the cloud
|
||||||
|
openspace-upload-skill --skill-dir /path/to/skill/dir # upload a trusted skill
|
||||||
|
```
|
||||||
|
|
||||||
|
### 📊 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_v2_1.gif" width="100%" alt="Skill Classes" /></td>
|
||||||
|
<td width="50%"><img src="assets/frontend_v2_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_v2_3.gif" width="100%" alt="Version Lineage" /></td>
|
||||||
|
<td width="50%"><img src="assets/frontend_v2_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>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Python API
|
||||||
|
|
||||||
|
Use the Python API when you want to embed OpenSpace inside your own runtime instead of launching it through MCP or the CLI.
|
||||||
|
|
||||||
|
```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())
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Framework
|
||||||
|
|
||||||
|
OpenSpace v2 has four connected layers. They match the problems above: judge skill quality, improve skills with control, share skills with context, and run agents with quality records.
|
||||||
|
|
||||||
|
### 📊 Skill Quality Layer
|
||||||
|
|
||||||
|
The quality layer answers the first question: **which skills can the agent trust?**
|
||||||
|
|
||||||
|
- **Skill outcomes** — Records whether a skill was selected, applied, completed the task, or fell back.
|
||||||
|
- **Tool reliability** — Tracks tool failures and slowdowns that can make a skill unreliable.
|
||||||
|
- **Task-result as evidence** — Uses real task behavior instead of skill descriptions alone.
|
||||||
|
|
||||||
|
**Result:** the skill folder becomes easier to trust because OpenSpace knows what worked in real runs.
|
||||||
|
|
||||||
|
### 🧬 Controlled Skill Evolution
|
||||||
|
|
||||||
|
The evolution layer answers the second question: **when should a skill change?**
|
||||||
|
|
||||||
|
- **FIX** — Repair a broken or outdated skill.
|
||||||
|
- **DERIVED** — Create a better or more specialized version from an existing skill.
|
||||||
|
- **CAPTURED** — Save one reusable subworkflow only when the source trace shows
|
||||||
|
both its execution and a separate validation of the claimed postcondition.
|
||||||
|
Whole-task success is neither required nor sufficient.
|
||||||
|
- **Independent capture review** — Before commit, one bounded semantic review
|
||||||
|
verifies that cited observations support the exact capability and that the
|
||||||
|
authored skill contains no broader or explicitly unverified procedure.
|
||||||
|
- **Provisional → trusted** — A validated evolved skill can be reused immediately as provisional; independent successful use promotes it to trusted, while an attributable failure demotes it.
|
||||||
|
- **Separate availability** — `enabled` controls reuse independently from the two-state trust lifecycle.
|
||||||
|
- **Audit-only candidates** — Blocked or uncertain proposals remain inspectable candidates; recurrence never auto-rechecks or promotes them into skills.
|
||||||
|
|
||||||
|
**Result:** agents can adapt to real-world change without turning every signal into noisy self-modification.
|
||||||
|
|
||||||
|
### 🌐 Local-First Skill Hub
|
||||||
|
|
||||||
|
The hub layer answers the third question: **how should skills be shared and reviewed?**
|
||||||
|
|
||||||
|
- **Local skill folders** — Agents run and reuse skills locally.
|
||||||
|
- **Package organization** — Cloud skills are grouped by package so people can browse them with context.
|
||||||
|
- **Explicit import** — A cloud skill enters a local folder before the agent reuses it.
|
||||||
|
- **Reviewable history** — Shared skills can show package, visibility, lineage, and quality signals.
|
||||||
|
|
||||||
|
**Result:** skills are shared as reviewable knowledge, not as a flat pile of files.
|
||||||
|
|
||||||
|
### 🛠️ Agent Harness with Quality Records
|
||||||
|
|
||||||
|
The harness layer answers the fourth question: **where does the quality evidence come from?**
|
||||||
|
|
||||||
|
- **Recoverable sessions** — Saves task history, tool results, and file changes for long-running work.
|
||||||
|
- **Permission-aware tools** — Validates tool calls and runs them with permission and sandbox checks.
|
||||||
|
- **Quality records** — Turns execution results into evidence for skill quality and evolution.
|
||||||
|
- **Shared runtime** — CLI, Python API, MCP, gateway, and dashboard use the same execution layer.
|
||||||
|
|
||||||
|
**Result:** OpenSpace can judge and evolve skills because agent work leaves clear, reusable records.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!--
|
||||||
|
## 🔧 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 | 🧬 Skill evolution | 🌐 Cloud | 🔧 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/ # Legacy v1 benchmark materials
|
||||||
|
├── examples/
|
||||||
|
│ └── my-daily-monitor/ # Legacy v1 generated example and assets
|
||||||
|
├── .openspace/ # Runtime: embedding cache + skill DB
|
||||||
|
└── logs/ # Execution logs & recordings
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## 🔗 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>
|
||||||
|
|
||||||
|
**📊 Help Your Agent Find Reliable Skills · 🧬 Evolve Under Control in Real Tasks · 🌐 A Hierarchical, Reviewable Skill Hub**
|
||||||
|
|
||||||
|
</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>
|
||||||
625
README_zh.md
Normal file
625
README_zh.md
Normal file
|
|
@ -0,0 +1,625 @@
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
<picture>
|
||||||
|
<img src="assets/logo_v2.png" width="280px" style="border: none; box-shadow: none;" alt="OpenSpace Logo">
|
||||||
|
</picture>
|
||||||
|
|
||||||
|
## OpenSpace:面向 Agent 的 Skill Hub - 质量优先,而非数量堆积
|
||||||
|
|
||||||
|
| 📊 **真实任务验证 Skill有效性** | 🧬 **证据驱动的可控进化** | 🌐 **层级化 Skill Hub** | 🛠️ **全程质量记录** |
|
||||||
|
|
||||||
|
[](https://modelcontextprotocol.io/)
|
||||||
|
[](https://www.python.org/)
|
||||||
|
[](https://opensource.org/licenses/MIT/)
|
||||||
|
[](./COMMUNICATION.md)
|
||||||
|
[](./COMMUNICATION.md)
|
||||||
|
[](./README.md)
|
||||||
|
[](https://github.com/HKUDS/OpenSpace/blob/v1/README_CN.md)
|
||||||
|
|
||||||
|
**一条命令,进化你所有的 AI Agent**:OpenClaw、nanobot、Claude Code、Codex、Cursor 等。
|
||||||
|
|
||||||
|
<img src="assets/cli-typing.gif" width="500px" alt="openspace --query your task">
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📢 最新动态
|
||||||
|
|
||||||
|
- **2026-07-17** 🚀 **OpenSpace v2 正式发布**:v2 将 OpenSpace 升级为质量优先的 Skill Hub,带来基于 package 的 Skill 浏览、Skill 质量摘要、task-trace 证据上传,以及更新后的本地 dashboard / TUI 体验。
|
||||||
|
|
||||||
|
- **2026-07-04** 📊 **浏览 v2 Skill 时可以看到质量摘要**:package 与 skill detail 视图可以展示 usage-quality summary;public lineage 页面在无法展示私有内容时会使用脱敏占位。
|
||||||
|
|
||||||
|
- **2026-07-03** 🔎 **Package 内 Skill 搜索与 task-trace 上传成为 v2 正式流程**:package 页面可以直接搜索 Skill;task trace 可以被校验、存储,并以幂等方式上传为质量证据。
|
||||||
|
|
||||||
|
- **2026-06-25** 🌐 **v2 cloud 路径对 public browse 与 private skill access 更稳定**:public 页面、private skill endpoint、前后端路由与 TLS access 被放到同一条验证链路中。
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>更早动态</summary>
|
||||||
|
|
||||||
|
- **2026-06-19** 🌐 **v2 public 页面可以免登录读取**:匿名用户可以浏览 public skills,已有用户获得 agent bootstrap 路径,search / recall service 也恢复可用。
|
||||||
|
|
||||||
|
- **2026-06-18** 🧭 **v2 cloud 体验更完整**:package、group、profile 与 agent 页面整合到更清晰的 package-browser 流程中,也配套了更结构化的 import 路径。
|
||||||
|
|
||||||
|
- **2026-06-03** 🪟 **Windows communication gateway 启动更可靠**:针对 Windows message adapter 的 process liveness check 完成 follow-up 修复。
|
||||||
|
|
||||||
|
- **2026-06-02** 🚀 **release v2 引入新的本地使用体验**:分支加入 v2 README、dashboard、TUI、runtime services、sandboxing、memory、scheduler、Skill evidence、evolution、triggers 与相关 assets。
|
||||||
|
|
||||||
|
- **2026-05-27** 🪟 **通信网关 Windows 兼容性改进**:gateway runtime 在 Windows 上改用 Windows API 检查 PID,同时保留 Unix fallback,修复 Windows 下 gateway 启动失败问题。
|
||||||
|
|
||||||
|
- **2026-05-14** 🧭 **Skill library、group detail 与 lineage history 扩展**:用户可以看到 owned-skill library、group 内 shared-skill 视图,以及 inactive relationship 的历史 lineage。
|
||||||
|
|
||||||
|
- **2026-05-13** 📈 **Package 与 Skill detail 页面更适合安全查看**:没有访问权限时私有内容会保持隐藏,task-step record 也让 Skill / package 视图更适合做质量分析。
|
||||||
|
|
||||||
|
- **2026-05-13** ⏱️ **长时间 shell 任务更可靠**:timeout 现在会清理 subprocess tree,post-task analysis 被加上边界,Skill search cache 写入也更安全。
|
||||||
|
|
||||||
|
- **2026-05-10** 📦 **v1 Skill 可以映射到 v2 identity record**:package 内 Skill 搜索会尊重 lineage visibility,单个 Skill bundle 也可以被直接拉取。
|
||||||
|
|
||||||
|
- **2026-05-09** 👥 **v2 group sharing 上线**:Skill 可以按 group scope 分享,同时 v1 与 v2 sharing path 保持隔离。
|
||||||
|
|
||||||
|
- **2026-05-01** 🏗️ **旧 Skill collection 更容易迁移到 v2 层级结构**:package migration 与 synthesis tooling 增加 deterministic sampling、更安全的 agent loop 和 resume check。
|
||||||
|
|
||||||
|
- **2026-04-29** 📡 **Search 与 package pull 开始产生质量记录**:v2 加入 telemetry-backed search、package pull、skill-use session 与 evolution telemetry,为后续质量摘要提供基础。
|
||||||
|
|
||||||
|
- **2026-04-22** 🛡️ **Upload、share、promote 流程更适合重试**:duplicate 与 replay 处理被加固,重复请求的行为更可预测。
|
||||||
|
|
||||||
|
- **2026-04-20** 🔎 **v2 Search 加入 lexical recall 与 semantic reranking**:package / skill discovery 不再只依赖精确文本匹配。
|
||||||
|
|
||||||
|
- **2026-04-18** 🛡️ **Sharing 与 promote-to-public 更可预测**:group / public visibility change 获得幂等行为和更安全的访问检查。
|
||||||
|
|
||||||
|
- **2026-04-18** ⚡ **本地 Skill 搜索 warm-up 后更快**:`search_skills` 复用 SkillRanker embedding cache,并在 Skill 文本变化时刷新 embedding。
|
||||||
|
|
||||||
|
- **2026-04-17** 🔄 **上传和分享后的 package search index 更可靠**:后台 rebuild 与 recovery 路径会保持 package search 数据同步。
|
||||||
|
|
||||||
|
- **2026-04-16** 🧬 **Evolution candidate 状态可追踪**:OpenSpace 可以记录 candidate processing state;macOS window / screenshot 能力也不会因为缺少 `atomacos` 而被误关。
|
||||||
|
|
||||||
|
- **2026-04-10** 🧩 **CAPTURED Skill 写入位置修正**:CAPTURED Skill 现在会写回正确的 host-agent skill directory。
|
||||||
|
|
||||||
|
- **2026-04-09** 💬 **WhatsApp 与 Feishu adapter 发布**:OpenSpace 新增 session management、attachment cache、allowlist,以及外部消息流程所需的 private-safe cloud upload compatibility。
|
||||||
|
|
||||||
|
- **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 正式开源!
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 当前 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) 等——已经很强大,但仍有一个关键弱点:它们不知道哪些 Skill 在真实使用后仍然可靠。
|
||||||
|
|
||||||
|
- **❌ Skill 越堆越多,却缺少质量信号** - Skill folder 可以很快变大,但弱 Skill 和强 Skill 经常看起来差不多。
|
||||||
|
- **❌ Agent 会重复坏经验** - 如果某个 Skill 曾经看起来有用,Agent 可能会一直选择它,即使它失败、fallback,或已经过时。
|
||||||
|
- **❌ Skill 演化很难控制** - 什么都改会制造噪音,什么都不改又无法适应真实世界。
|
||||||
|
- **❌ 共享缺少信任** - 下载来的 Skill 可能看起来很完整,但用户很难看出它来自哪里、如何变化、是否真的帮助完成过任务。
|
||||||
|
|
||||||
|
## 🎯 什么是 OpenSpace?
|
||||||
|
|
||||||
|
**🚀 OpenSpace 是一个质量优先的 Skill Hub:让真实任务教会 Agent 哪些 Skill 值得信任、复用、改进和共享。**
|
||||||
|
|
||||||
|
https://github.com/user-attachments/assets/1c6b1b44-b207-491b-ad23-0f0591c17e0a
|
||||||
|
|
||||||
|
OpenSpace 以 Skill 的形式接入你的 Agent。v1 帮助 Agent 学习、进化和共享经验;v2 补上了缺失的质量层:每个有用的 Skill 都应该由真实任务结果来判断,通过受控演化来改进,并带着清晰上下文被共享。
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<img src="assets/skillwiki.png" width="760" alt="OpenSpace Skill Wiki package tree and skill search visualization">
|
||||||
|
<br />
|
||||||
|
<sub>Skill Wiki 将共享 Skill 组织成可搜索的 package tree,并保留谱系与质量上下文。</sub>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
OpenSpace v2 给 Agent 四项实际能力:
|
||||||
|
|
||||||
|
### 📊 来自真实任务的 Skill 质量判断
|
||||||
|
|
||||||
|
知道哪些 Skill 真的有帮助。
|
||||||
|
|
||||||
|
- ✅ **任务结果质量** — 跟踪某个 Skill 是否被选中、是否被应用、是否完成任务、是否 fallback。
|
||||||
|
- ✅ **工具可靠性** — 记录工具何时失败、变慢,或对依赖它的 Skill 变得有风险。
|
||||||
|
- ✅ **可感知质量的复用** — 质量弱的 Skill 不会和持续帮助真实任务的 Skill 被同等对待。
|
||||||
|
- ✅ **清晰证据** — 用户可以查看真实运行行为,而不是只相信 Skill 文本。
|
||||||
|
|
||||||
|
**一个 Skill 有用,不是因为它写得好看,而是因为它在真实任务中有效。**
|
||||||
|
|
||||||
|
### 🧬 受控 Skill 演化
|
||||||
|
|
||||||
|
从经验中改进,但不是盲目地什么都改。
|
||||||
|
|
||||||
|
- ✅ **证据驱动更新** — 真实任务证据决定一个 Skill 应该被修复、派生,还是捕获为新 Skill。
|
||||||
|
- ✅ **先 provisional** — 新演化出的 Skill 可以复用,但会保持 provisional,直到真实的跨任务成功将其晋级为 trusted。
|
||||||
|
- ✅ **信任与可用性分离** — Skill 可以是 provisional 或 trusted,操作者仍可独立启用或禁用它。
|
||||||
|
- ✅ **验证后的 Skill** — 新版本替换旧版本之前会先被检查。
|
||||||
|
- ✅ **版本历史** — 用户可以看到一个 Skill 如何随时间变化。
|
||||||
|
|
||||||
|
**Agent 应该适应真实世界,但每一次改变都需要控制。**
|
||||||
|
|
||||||
|
### 🌐 本地优先的 Skill Hub
|
||||||
|
|
||||||
|
带着上下文共享 Skill,而不是把文件堆成一堆。
|
||||||
|
|
||||||
|
- ✅ **本地优先工作流** — 你的 Agent 可以在本地运行、搜索和演化 Skill。
|
||||||
|
- ✅ **Package 组织** — Cloud Skill 按 package 分组,方便人浏览和审阅。
|
||||||
|
- ✅ **显式导入** — Cloud Skill 会先导入本地 Skill folder,再被复用。
|
||||||
|
- ✅ **可审阅共享** — 共享 Skill 会带上 package、可见性、历史和质量信号等上下文。
|
||||||
|
|
||||||
|
**云端帮助人组织和审阅 Skill;本地执行仍在你的控制中。**
|
||||||
|
|
||||||
|
### 🛠️ 带质量记录的 Agent Harness
|
||||||
|
|
||||||
|
让 Agent 的执行过程留下有用证据。
|
||||||
|
|
||||||
|
- ✅ **可恢复会话** — 长任务可以保留任务历史、工具结果和文件。
|
||||||
|
- ✅ **权限感知工具** — 工具调用会经过验证、权限和沙箱检查。
|
||||||
|
- ✅ **质量记录** — 执行过程会产生用于质量判断和演化的证据。
|
||||||
|
- ✅ **统一 runtime 边界** — CLI、Python API、MCP、gateway 和 dashboard 共享同一套执行模型。
|
||||||
|
|
||||||
|
**OpenSpace 不只是运行任务;它还记录让 Skill 变可信的证据。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 核心差异
|
||||||
|
|
||||||
|
**❌ 当前的 Agent**
|
||||||
|
|
||||||
|
- 它们可以保存 Skill、prompt 和笔记,但不知道哪些仍然有效。
|
||||||
|
- 它们会重复同样的失败,因为坏经验没有被清晰记录。
|
||||||
|
- 它们要么避免自我改进,要么容易产生噪音式、不受控的修改。
|
||||||
|
- 共享 Skill 很难信任,因为质量没有和真实任务结果绑定。
|
||||||
|
|
||||||
|
**✅ OpenSpace v1**
|
||||||
|
|
||||||
|
- 给 Agent 一个可以跨任务复用的 Skill 记忆。
|
||||||
|
- 从成功工作流和失败执行中学习。
|
||||||
|
- 通过 FIX、DERIVED、CAPTURED 更新来演化 Skill。
|
||||||
|
- 共享演化后的 Skill,让一个 Agent 的经验帮助另一个 Agent。
|
||||||
|
|
||||||
|
**✅ OpenSpace v2**
|
||||||
|
|
||||||
|
- 保留 v1 的学习循环,但把质量作为核心信号。
|
||||||
|
- 用任务结果判断 Skill:是否被选中、应用、完成、失败或 fallback。
|
||||||
|
- 通过受控、证据驱动的更新来演化 Skill。
|
||||||
|
- 按 package 组织 Cloud Skill,并在复用前导入本地 Skill folder。
|
||||||
|
- 在 Agent harness 中运行任务,记录质量和演化所需的证据。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 目录
|
||||||
|
|
||||||
|
- [⚡ 快速开始](#-快速开始)
|
||||||
|
- [🤖 路径 A:为你的 Agent 接入](#-路径-a为你的-agent-接入)
|
||||||
|
- [👤 路径 B:命令行使用](#-路径-b命令行使用)
|
||||||
|
- [📊 本地仪表盘](#-本地仪表盘)
|
||||||
|
- [🏗️ 框架](#框架)
|
||||||
|
- [📊 Skill 质量层](#-skill-质量层)
|
||||||
|
- [🧬 受控 Skill 演化](#-受控-skill-演化)
|
||||||
|
- [🌐 本地优先的 Skill Hub](#-本地优先的-skill-hub)
|
||||||
|
- [🛠️ 带质量记录的 Agent Harness](#️-带质量记录的-agent-harness)
|
||||||
|
- [📖 代码结构](#-代码结构)
|
||||||
|
- [🔗 相关项目](#-相关项目)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚡ 快速开始
|
||||||
|
|
||||||
|
🌐 **只想看看?** 在 **[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 --no-cone '/*' '!/assets/'
|
||||||
|
> pip install -e .
|
||||||
|
> ```
|
||||||
|
|
||||||
|
**选择你的路径:**
|
||||||
|
- **[路径 A](#-路径-a为你的-agent-接入)** — 将 OpenSpace 接入你的 Agent
|
||||||
|
- **[路径 B](#-路径-b命令行使用)** — 直接从命令行使用 OpenSpace
|
||||||
|
|
||||||
|
### 🤖 路径 A:为你的 Agent 接入
|
||||||
|
|
||||||
|
适用于任何可启动 MCP server 并读取 Skill(`SKILL.md`)的宿主。OpenSpace 内置 OpenClaw 与 nanobot 的宿主辅助能力,也可以手动接入 Claude Code、Codex、Cursor 或其他支持 MCP 的 Agent。
|
||||||
|
|
||||||
|
**给你的 Agent**
|
||||||
|
|
||||||
|
打开 coding agent,然后粘贴:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Install OpenSpace for this host agent.
|
||||||
|
|
||||||
|
If an OpenSpace repo is already open, use its current repository root as
|
||||||
|
OPENSPACE_WORKSPACE. Otherwise, clone it first:
|
||||||
|
`git clone https://github.com/HKUDS/OpenSpace.git && cd OpenSpace`
|
||||||
|
|
||||||
|
First read:
|
||||||
|
- README_zh.md or README.md -> Quick Start -> Path A
|
||||||
|
- openspace/host_skills/README.md -> exact setup for this host
|
||||||
|
- openspace/.env.example only if model or cloud credentials are needed
|
||||||
|
|
||||||
|
Then:
|
||||||
|
1. Verify a Python 3.12+ interpreter is available. If `openspace-mcp --help`
|
||||||
|
is unavailable, install OpenSpace from this repo with that interpreter:
|
||||||
|
`python -m pip install -e .`
|
||||||
|
2. Detect this host agent's MCP config file/format and local skill directory.
|
||||||
|
Preserve existing config and unrelated MCP servers.
|
||||||
|
3. Configure an MCP server named `openspace`. Prefer stdio for local use:
|
||||||
|
`command: openspace-mcp`. Use streamable HTTP only if this host cannot use
|
||||||
|
stdio or needs a standalone/remote server.
|
||||||
|
4. Set `OPENSPACE_WORKSPACE` to the absolute repo root and
|
||||||
|
`OPENSPACE_HOST_SKILL_DIRS` to the host agent's skill directory.
|
||||||
|
5. Copy `openspace/host_skills/delegate-task` and
|
||||||
|
`openspace/host_skills/skill-discovery` into the host agent's skill directory.
|
||||||
|
6. If cloud access is required, use `openspace-cloud-auth bootstrap-agent-key`.
|
||||||
|
Do not ask me to paste secrets into chat; stop if a required credential or
|
||||||
|
email is missing.
|
||||||
|
7. Reload or restart the host agent if its MCP/skill system requires it.
|
||||||
|
|
||||||
|
Do not report success until `openspace-mcp --help` works, the MCP client can see
|
||||||
|
OpenSpace tools, a lightweight local skill search works, and long `execute_task`
|
||||||
|
calls have a timeout of at least 600 seconds. In your final report, include the
|
||||||
|
MCP config path, skill directory, chosen transport, and verification results. If
|
||||||
|
any path, config format, Python version, credential, MCP transport, or skill
|
||||||
|
directory is missing, stop and tell me exactly what is missing.
|
||||||
|
```
|
||||||
|
|
||||||
|
**安装步骤(手动或由 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,参见 [Skills](#skills)。
|
||||||
|
|
||||||
|
> [!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:命令行使用
|
||||||
|
|
||||||
|
直接从命令行使用 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"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Skills
|
||||||
|
|
||||||
|
项目级 Skill 放在 `.openspace/skills/<skill-name>/` 目录下。每个 Skill 是一个目录,目录内包含 `SKILL.md`,也可以放辅助文件:
|
||||||
|
|
||||||
|
```text
|
||||||
|
.openspace/
|
||||||
|
└── skills/
|
||||||
|
├── my-skill/
|
||||||
|
│ └── SKILL.md
|
||||||
|
└── another-skill/
|
||||||
|
├── SKILL.md
|
||||||
|
└── helper.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>发现顺序、ID 与安全</summary>
|
||||||
|
|
||||||
|
OpenSpace 会依次发现 `OPENSPACE_HOST_SKILL_DIRS`、配置里的 `skills.skill_dirs`、项目级目录(例如 `.openspace/skills`)、用户级目录(例如 `~/.openspace/skills`),最后才加载 `openspace/skills` 中随 OpenSpace 发布的内置 Skill。
|
||||||
|
|
||||||
|
每个被发现的 Skill 都有一个 `.skill_id` sidecar,用于稳定追踪。新的项目级或用户级 Skill 可以先不包含它;OpenSpace 会在首次发现时创建。复制 Skill 且希望保持同一个逻辑 Skill 时,请保留 `.skill_id`;如果是在创建新的独立 Skill,请在首次发现前删除它。公开和私有云端上传都要求对应的本地 SkillStore 记录为 `trusted`;`provisional` 或状态未知时会在本地 fail closed。trust 状态不会发送到云端,`.skill_id` 也不会作为普通文件上传。
|
||||||
|
|
||||||
|
所有发现到的 Skill 在加载前都会经过 `check_skill_safety`。包含危险模式的 Skill,例如 prompt injection 或凭证外泄,会被阻断并记录日志。
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
**Cloud CLI** — 从命令行管理 Skill:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openspace-download-skill <skill_id> # 从云端下载 Skill
|
||||||
|
openspace-upload-skill --skill-dir /path/to/skill/dir # 上传 trusted Skill
|
||||||
|
```
|
||||||
|
|
||||||
|
### 📊 本地仪表盘
|
||||||
|
|
||||||
|
查看你的 Skill 如何进化——浏览 Skill、追踪谱系、比较差异。
|
||||||
|
|
||||||
|
> 需要 **Node.js ≥ 20**。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Terminal 1:启动后端 API
|
||||||
|
openspace-dashboard --port 7788
|
||||||
|
|
||||||
|
# Terminal 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_v2_1.gif" width="100%" alt="Skill Classes" /></td>
|
||||||
|
<td width="50%"><img src="assets/frontend_v2_2.gif" width="100%" alt="Cloud Skill Records" /></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center"><sub>Skill Classes — 浏览、搜索与排序</sub></td>
|
||||||
|
<td align="center"><sub>Cloud — 浏览与发现 Skill Records</sub></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td width="50%"><img src="assets/frontend_v2_3.gif" width="100%" alt="Version Lineage" /></td>
|
||||||
|
<td width="50%"><img src="assets/frontend_v2_4.gif" width="100%" alt="Workflow Sessions" /></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center"><sub>Version Lineage — Skill 进化图谱</sub></td>
|
||||||
|
<td align="center"><sub>Workflow Sessions — 执行历史与指标</sub></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Python API
|
||||||
|
|
||||||
|
当你希望把 OpenSpace 嵌入到自己的 Python runtime,而不是通过 MCP 或 CLI 启动时,可以使用 Python API。
|
||||||
|
|
||||||
|
```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())
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 框架
|
||||||
|
|
||||||
|
OpenSpace v2 有四个相互连接的层。它们对应前面的问题:判断 Skill 质量、受控地改进 Skill、带上下文共享 Skill,以及用质量记录运行 Agent。
|
||||||
|
|
||||||
|
### 📊 Skill 质量层
|
||||||
|
|
||||||
|
质量层回答第一个问题:**哪些 Skill 值得 Agent 信任?**
|
||||||
|
|
||||||
|
- **Skill 结果** — 记录某个 Skill 是否被选中、被应用、完成任务,或发生 fallback。
|
||||||
|
- **工具可靠性** — 跟踪工具失败和变慢,因为这些问题会让依赖它的 Skill 变得不可靠。
|
||||||
|
- **任务结果作为证据** — 使用真实任务行为,而不是只看 Skill 描述。
|
||||||
|
|
||||||
|
**结果**:OpenSpace 知道哪些 Skill 在真实运行中有效,因此 Skill folder 更值得信任。
|
||||||
|
|
||||||
|
### 🧬 受控 Skill 演化
|
||||||
|
|
||||||
|
演化层回答第二个问题:**什么时候应该修改 Skill?**
|
||||||
|
|
||||||
|
- **FIX** — 修复损坏或过时的 Skill。
|
||||||
|
- **DERIVED** — 从已有 Skill 创建更好或更专门的版本。
|
||||||
|
- **CAPTURED** — 从成功任务中保存新的可复用工作流。
|
||||||
|
- **provisional → trusted** — 通过验证的 evolved Skill 会先以 provisional 状态参与复用;独立的成功使用会将其晋级为 trusted,可归因失败则使其降级。
|
||||||
|
- **可用性独立** — `enabled` 独立控制是否参与复用,不与两态 trust 生命周期混在一起。
|
||||||
|
- **candidate 仅用于审计** — 被阻止或不确定的 proposal 会保留为可查看的 candidate;recurrence 不会自动触发复审或将其晋级为 Skill。
|
||||||
|
|
||||||
|
**结果**:Agent 可以适应真实世界变化,但不会把每个信号都变成噪音式自我修改。
|
||||||
|
|
||||||
|
### 🌐 本地优先的 Skill Hub
|
||||||
|
|
||||||
|
Hub 层回答第三个问题:**Skill 应该如何共享和审阅?**
|
||||||
|
|
||||||
|
- **本地 Skill folder** — Agent 在本地运行和复用 Skill。
|
||||||
|
- **Package 组织** — Cloud Skill 按 package 分组,方便人带着上下文浏览。
|
||||||
|
- **显式导入** — Cloud Skill 会先进入本地 folder,再被 Agent 复用。
|
||||||
|
- **可审阅历史** — 共享 Skill 可以展示 package、可见性、演化历史和质量信号。
|
||||||
|
|
||||||
|
**结果**:Skill 作为可审阅的知识被共享,而不是一堆扁平文件。
|
||||||
|
|
||||||
|
### 🛠️ 带质量记录的 Agent Harness
|
||||||
|
|
||||||
|
Harness 层回答第四个问题:**质量证据从哪里来?**
|
||||||
|
|
||||||
|
- **可恢复会话** — 保存长任务的任务历史、工具结果和文件变化。
|
||||||
|
- **权限感知工具** — 校验工具调用,并通过权限和沙箱检查运行。
|
||||||
|
- **质量记录** — 将执行结果转化为 Skill 质量和演化所需的证据。
|
||||||
|
- **共享 runtime** — CLI、Python API、MCP、gateway 和 dashboard 使用同一套执行层。
|
||||||
|
|
||||||
|
**结果**:Agent 工作会留下清晰、可复用的记录,因此 OpenSpace 才能判断和演化 Skill。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="-代码结构"></a>
|
||||||
|
<details>
|
||||||
|
<summary><b>📖 代码结构</b></summary>
|
||||||
|
|
||||||
|
> **图例**:⚡ 核心模块 | 🧬 Skill 进化 | 🌐 云端 | 🔧 支撑模块
|
||||||
|
|
||||||
|
```text
|
||||||
|
OpenSpace/
|
||||||
|
├── openspace/
|
||||||
|
│ ├── runtime/ # Runtime 拥有的服务、状态、会话/工作区编排与执行生命周期
|
||||||
|
│ ├── 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 # Smart Tool RAG(BM25 + embedding + LLM)
|
||||||
|
│ │ │ ├── quality/ # 工具质量追踪与自我进化
|
||||||
|
│ │ │ ├── security/ # 策略、沙箱、E2B
|
||||||
|
│ │ │ ├── meta/ # Meta provider 与工具
|
||||||
|
│ │ │ ├── transport/ # 连接器与任务管理器
|
||||||
|
│ │ │ └── tool/ # 工具抽象(基础、本地、远程)
|
||||||
|
│ │ └── backends/
|
||||||
|
│ │ ├── shell/ # Shell 命令执行
|
||||||
|
│ │ ├── gui/ # Anthropic Computer Use
|
||||||
|
│ │ ├── mcp/ # Model Context Protocol(stdio、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/ # Dashboard UI(React + Tailwind)
|
||||||
|
│ └── tui/ # TypeScript 终端 UI
|
||||||
|
├── benchmarks/
|
||||||
|
│ └── gdpval/ # v1 历史 benchmark 素材
|
||||||
|
├── examples/
|
||||||
|
│ └── my-daily-monitor/ # v1 历史生成示例与素材
|
||||||
|
├── .openspace/ # 运行时:embedding 缓存 + Skill 数据库
|
||||||
|
└── logs/ # 执行日志与录制
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## 🔗 相关项目
|
||||||
|
|
||||||
|
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 识别可靠 Skill · 🧬 在真实任务中受控进化 · 🌐 一个层级化、可审阅的 Skill Hub**
|
||||||
|
|
||||||
|
</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>
|
||||||
4
apps/dashboard/.env.example
Normal file
4
apps/dashboard/.env.example
Normal 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
98
apps/dashboard/README.md
Normal 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
16
apps/dashboard/index.html
Normal 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
3580
apps/dashboard/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
32
apps/dashboard/package.json
Normal file
32
apps/dashboard/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
apps/dashboard/postcss.config.js
Normal file
6
apps/dashboard/postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
BIN
apps/dashboard/public/openspace_icon.webp
Normal file
BIN
apps/dashboard/public/openspace_icon.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
30
apps/dashboard/src/App.tsx
Normal file
30
apps/dashboard/src/App.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
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';
|
||||||
|
import AgentTracePage from './pages/AgentTracePage';
|
||||||
|
|
||||||
|
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 /> },
|
||||||
|
{ path: 'workflows/:workflowId/trace', element: <AgentTracePage /> },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return <RouterProvider router={router} />;
|
||||||
|
}
|
||||||
11
apps/dashboard/src/api/client.ts
Normal file
11
apps/dashboard/src/api/client.ts
Normal 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;
|
||||||
126
apps/dashboard/src/api/evolution.ts
Normal file
126
apps/dashboard/src/api/evolution.ts
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
import apiClient from './client';
|
||||||
|
import type {
|
||||||
|
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 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;
|
||||||
|
},
|
||||||
|
};
|
||||||
33
apps/dashboard/src/api/index.ts
Normal file
33
apps/dashboard/src/api/index.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
export { default as apiClient } from './client';
|
||||||
|
export { evolutionApi } from './evolution';
|
||||||
|
export { overviewApi } from './overview';
|
||||||
|
export { skillsApi } from './skills';
|
||||||
|
export { workflowsApi } from './workflows';
|
||||||
|
export type {
|
||||||
|
EvidenceRef,
|
||||||
|
EvidenceRefPreview,
|
||||||
|
ExecutionAnalysis,
|
||||||
|
EvolutionAction,
|
||||||
|
EvolutionCandidate,
|
||||||
|
EvolutionJob,
|
||||||
|
EvolutionReviewItem,
|
||||||
|
OverviewResponse,
|
||||||
|
PipelineStage,
|
||||||
|
QualitySignalAuditRow,
|
||||||
|
Skill,
|
||||||
|
SkillDetail,
|
||||||
|
SkillLineage,
|
||||||
|
SkillLineageEdge,
|
||||||
|
SkillLineageMeta,
|
||||||
|
SkillLineageNode,
|
||||||
|
SkillSource,
|
||||||
|
SkillStats,
|
||||||
|
WorkflowArtifact,
|
||||||
|
WorkflowDetail,
|
||||||
|
WorkflowSummary,
|
||||||
|
WorkflowTrace,
|
||||||
|
WorkflowTraceDatum,
|
||||||
|
WorkflowTraceEvent,
|
||||||
|
WorkflowTraceSummary,
|
||||||
|
WorkflowTimelineEvent,
|
||||||
|
} from './types';
|
||||||
9
apps/dashboard/src/api/overview.ts
Normal file
9
apps/dashboard/src/api/overview.ts
Normal 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;
|
||||||
|
},
|
||||||
|
};
|
||||||
31
apps/dashboard/src/api/skills.ts
Normal file
31
apps/dashboard/src/api/skills.ts
Normal 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;
|
||||||
|
},
|
||||||
|
};
|
||||||
398
apps/dashboard/src/api/types.ts
Normal file
398
apps/dashboard/src/api/types.ts
Normal file
|
|
@ -0,0 +1,398 @@
|
||||||
|
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: '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 WorkflowSummary {
|
||||||
|
id: string;
|
||||||
|
path: string;
|
||||||
|
log_root?: string | null;
|
||||||
|
log_root_label?: string | null;
|
||||||
|
log_folder?: string | null;
|
||||||
|
log_folder_label?: string | null;
|
||||||
|
log_relative_path?: string | null;
|
||||||
|
task_id: string;
|
||||||
|
task_name: string;
|
||||||
|
recording_task_id?: string | null;
|
||||||
|
benchmark_task_id?: string | null;
|
||||||
|
benchmark_task_run_id?: string | null;
|
||||||
|
benchmark_run_name?: string | null;
|
||||||
|
instruction_source?: string | null;
|
||||||
|
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 WorkflowTraceDatum {
|
||||||
|
label: string;
|
||||||
|
kind: string;
|
||||||
|
preview: string;
|
||||||
|
value: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkflowTraceEvent {
|
||||||
|
event_id: string;
|
||||||
|
sequence: number;
|
||||||
|
timestamp: string;
|
||||||
|
iteration?: number | null;
|
||||||
|
harness: string;
|
||||||
|
source: string;
|
||||||
|
title: string;
|
||||||
|
summary: string;
|
||||||
|
based_on: string[];
|
||||||
|
decision: string;
|
||||||
|
impact: string;
|
||||||
|
status?: string | null;
|
||||||
|
agent_name?: string | null;
|
||||||
|
tool_name?: string | null;
|
||||||
|
backend?: string | null;
|
||||||
|
inputs: WorkflowTraceDatum[];
|
||||||
|
outputs: WorkflowTraceDatum[];
|
||||||
|
metadata: Record<string, unknown>;
|
||||||
|
raw: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkflowTraceSummary {
|
||||||
|
total_events: number;
|
||||||
|
harness_counts: Record<string, number>;
|
||||||
|
agents: string[];
|
||||||
|
tools: string[];
|
||||||
|
iterations: number[];
|
||||||
|
has_conversation_log: boolean;
|
||||||
|
has_agent_actions: boolean;
|
||||||
|
has_tool_trajectory: boolean;
|
||||||
|
source_files: Record<string, string>;
|
||||||
|
workflow_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkflowTrace {
|
||||||
|
summary: WorkflowTraceSummary;
|
||||||
|
events: WorkflowTraceEvent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
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[];
|
||||||
|
trace?: WorkflowTrace;
|
||||||
|
artifacts: {
|
||||||
|
init_screenshot_url: string | null;
|
||||||
|
screenshots: WorkflowArtifact[];
|
||||||
|
video_url: string | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
14
apps/dashboard/src/api/workflows.ts
Normal file
14
apps/dashboard/src/api/workflows.ts
Normal 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;
|
||||||
|
},
|
||||||
|
};
|
||||||
13
apps/dashboard/src/components/EmptyState.tsx
Normal file
13
apps/dashboard/src/components/EmptyState.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
65
apps/dashboard/src/components/ErrorBoundary.tsx
Normal file
65
apps/dashboard/src/components/ErrorBoundary.tsx
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
91
apps/dashboard/src/components/LineageGraph.tsx
Normal file
91
apps/dashboard/src/components/LineageGraph.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
apps/dashboard/src/components/MetricCard.tsx
Normal file
22
apps/dashboard/src/components/MetricCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
apps/dashboard/src/components/ProgressBar.tsx
Normal file
21
apps/dashboard/src/components/ProgressBar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
250
apps/dashboard/src/components/skill-detail/DiffViewer.tsx
Normal file
250
apps/dashboard/src/components/skill-detail/DiffViewer.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
197
apps/dashboard/src/hooks/useSkillEvolutionGraphData.ts
Normal file
197
apps/dashboard/src/hooks/useSkillEvolutionGraphData.ts
Normal 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 };
|
||||||
|
}
|
||||||
401
apps/dashboard/src/i18n/en.json
Normal file
401
apps/dashboard/src/i18n/en.json
Normal file
|
|
@ -0,0 +1,401 @@
|
||||||
|
{
|
||||||
|
"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",
|
||||||
|
"openAgentTrace": "Agent Trace",
|
||||||
|
"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",
|
||||||
|
"traceEvents": "Trace events",
|
||||||
|
"traceEventsHint": "{{count}} harness categories",
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"agentTrace": {
|
||||||
|
"title": "Agent Trace",
|
||||||
|
"loading": "Loading agent trace…",
|
||||||
|
"failedToLoad": "Failed to load agent trace",
|
||||||
|
"notFound": "Agent trace not found",
|
||||||
|
"noLogRuns": "No log runs were found",
|
||||||
|
"backToWorkflow": "← Back to workflow",
|
||||||
|
"started": "Started",
|
||||||
|
"status": "Status",
|
||||||
|
"events": "Events",
|
||||||
|
"tools": "Tools",
|
||||||
|
"logFolder": "Log folder",
|
||||||
|
"allLogFolders": "All logs · {{count}}",
|
||||||
|
"runLog": "Run log",
|
||||||
|
"latestRun": "Latest run",
|
||||||
|
"currentLogPath": "Current log path",
|
||||||
|
"allHarnesses": "All harnesses",
|
||||||
|
"searchPlaceholder": "Search trace, tool, memory, input, output, impact",
|
||||||
|
"eventStream": "Event stream",
|
||||||
|
"visibleEvents": "{{count}} visible events",
|
||||||
|
"noEvents": "No trace events match the current filter.",
|
||||||
|
"basedOn": "Based on",
|
||||||
|
"decision": "Decision",
|
||||||
|
"impact": "Impact",
|
||||||
|
"inputs": "Inputs",
|
||||||
|
"outputs": "Outputs",
|
||||||
|
"sequence": "Sequence",
|
||||||
|
"time": "Time",
|
||||||
|
"iteration": "Iteration",
|
||||||
|
"source": "Source",
|
||||||
|
"agent": "Agent",
|
||||||
|
"tool": "Tool",
|
||||||
|
"backend": "Backend",
|
||||||
|
"ioCount": "I/O {{inputs}}/{{outputs}}",
|
||||||
|
"showFullJson": "Show full JSON",
|
||||||
|
"showFullValue": "Show full value",
|
||||||
|
"inputCount_one": "{{count}} input",
|
||||||
|
"inputCount_other": "{{count}} inputs",
|
||||||
|
"outputCount_one": "{{count}} output",
|
||||||
|
"outputCount_other": "{{count}} outputs",
|
||||||
|
"noInputs": "No structured input was recorded for this event.",
|
||||||
|
"noOutputs": "No structured output was recorded for this event.",
|
||||||
|
"metadata": "Metadata",
|
||||||
|
"raw": "Raw JSON",
|
||||||
|
"noDecision": "No explicit decision was recorded.",
|
||||||
|
"noImpact": "No explicit impact was recorded.",
|
||||||
|
"noEventSelected": "Select an event to inspect it."
|
||||||
|
},
|
||||||
|
"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": "Inspect-only candidates and blocked decisions",
|
||||||
|
"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": "Audit review entry",
|
||||||
|
"reviewQueue": "Audit review queue",
|
||||||
|
"noReviewItems": "No review items",
|
||||||
|
"noReviewItemsDesc": "Audit candidates and admission or validation blockers will appear here. The connected evidence DB has no pending review items.",
|
||||||
|
"inspectReview": "Inspect",
|
||||||
|
"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": "{{count}} source task observations",
|
||||||
|
"targets": "{{count}} targets",
|
||||||
|
"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": "Inspect-only audit item",
|
||||||
|
"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": "Legacy 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
34
apps/dashboard/src/i18n/index.ts
Normal file
34
apps/dashboard/src/i18n/index.ts
Normal 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;
|
||||||
401
apps/dashboard/src/i18n/zh.json
Normal file
401
apps/dashboard/src/i18n/zh.json
Normal file
|
|
@ -0,0 +1,401 @@
|
||||||
|
{
|
||||||
|
"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": "工作流详情",
|
||||||
|
"openAgentTrace": "Agent Trace",
|
||||||
|
"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}} 个工具事件",
|
||||||
|
"traceEvents": "Trace 事件",
|
||||||
|
"traceEventsHint": "{{count}} 类 harness",
|
||||||
|
"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}} 次成功迭代"
|
||||||
|
},
|
||||||
|
"agentTrace": {
|
||||||
|
"title": "Agent Trace",
|
||||||
|
"loading": "正在加载 Agent trace…",
|
||||||
|
"failedToLoad": "加载 Agent trace 失败",
|
||||||
|
"notFound": "未找到 Agent trace",
|
||||||
|
"noLogRuns": "没有找到可查看的日志运行",
|
||||||
|
"backToWorkflow": "← 返回工作流",
|
||||||
|
"started": "开始时间",
|
||||||
|
"status": "状态",
|
||||||
|
"events": "事件",
|
||||||
|
"tools": "工具",
|
||||||
|
"logFolder": "日志文件夹",
|
||||||
|
"allLogFolders": "全量日志 · {{count}}",
|
||||||
|
"runLog": "运行日志",
|
||||||
|
"latestRun": "最新运行",
|
||||||
|
"currentLogPath": "当前日志路径",
|
||||||
|
"allHarnesses": "全部 harness",
|
||||||
|
"searchPlaceholder": "搜索 trace、工具、记忆、输入、输出、影响",
|
||||||
|
"eventStream": "事件流",
|
||||||
|
"visibleEvents": "当前显示 {{count}} 个事件",
|
||||||
|
"noEvents": "当前过滤条件下没有 trace 事件。",
|
||||||
|
"basedOn": "依据",
|
||||||
|
"decision": "决定",
|
||||||
|
"impact": "影响",
|
||||||
|
"inputs": "输入",
|
||||||
|
"outputs": "输出",
|
||||||
|
"sequence": "序号",
|
||||||
|
"time": "时间",
|
||||||
|
"iteration": "迭代",
|
||||||
|
"source": "来源",
|
||||||
|
"agent": "Agent",
|
||||||
|
"tool": "工具",
|
||||||
|
"backend": "后端",
|
||||||
|
"ioCount": "I/O {{inputs}}/{{outputs}}",
|
||||||
|
"showFullJson": "查看完整 JSON",
|
||||||
|
"showFullValue": "查看完整值",
|
||||||
|
"inputCount_one": "{{count}} 个输入",
|
||||||
|
"inputCount_other": "{{count}} 个输入",
|
||||||
|
"outputCount_one": "{{count}} 个输出",
|
||||||
|
"outputCount_other": "{{count}} 个输出",
|
||||||
|
"noInputs": "此事件未记录结构化输入。",
|
||||||
|
"noOutputs": "此事件未记录结构化输出。",
|
||||||
|
"metadata": "元数据",
|
||||||
|
"raw": "原始 JSON",
|
||||||
|
"noDecision": "此事件未记录明确决定。",
|
||||||
|
"noImpact": "此事件未记录明确影响。",
|
||||||
|
"noEventSelected": "选择一个事件查看详情。"
|
||||||
|
},
|
||||||
|
"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": "查看",
|
||||||
|
"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}} 个 source task",
|
||||||
|
"targets": "{{count}} 个目标",
|
||||||
|
"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": "未捕获到指令"
|
||||||
|
}
|
||||||
|
}
|
||||||
1076
apps/dashboard/src/index.css
Normal file
1076
apps/dashboard/src/index.css
Normal file
File diff suppressed because it is too large
Load diff
76
apps/dashboard/src/layouts/MainLayout.tsx
Normal file
76
apps/dashboard/src/layouts/MainLayout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
apps/dashboard/src/main.tsx
Normal file
14
apps/dashboard/src/main.tsx
Normal 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>,
|
||||||
|
);
|
||||||
617
apps/dashboard/src/pages/AgentTracePage.tsx
Normal file
617
apps/dashboard/src/pages/AgentTracePage.tsx
Normal file
|
|
@ -0,0 +1,617 @@
|
||||||
|
import { useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||||
|
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { workflowsApi, type WorkflowDetail, type WorkflowSummary, type WorkflowTraceDatum, type WorkflowTraceEvent } from '../api';
|
||||||
|
import { formatDate, formatInstruction } from '../utils/format';
|
||||||
|
|
||||||
|
type TraceMetricProps = {
|
||||||
|
label: string;
|
||||||
|
value: ReactNode;
|
||||||
|
hint: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function stringify(value: unknown): string {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(value, null, 2);
|
||||||
|
} catch {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function collapseWhitespace(value: string): string {
|
||||||
|
return value.replace(/\s+/g, ' ').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncate(value: string, max = 220): string {
|
||||||
|
const compact = collapseWhitespace(value);
|
||||||
|
if (compact.length <= max) {
|
||||||
|
return compact;
|
||||||
|
}
|
||||||
|
return `${compact.slice(0, max).trimEnd()}...`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function humanize(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 formatTime(value?: string | null): string {
|
||||||
|
if (!value) {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
const date = new Date(value);
|
||||||
|
if (!Number.isNaN(date.getTime())) {
|
||||||
|
return new Intl.DateTimeFormat('zh-CN', {
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
}).format(date);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function metricHint(count: number, singular: string, plural = `${singular}s`): string {
|
||||||
|
return `${count} ${count === 1 ? singular : plural}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TraceMetric({ label, value, hint }: TraceMetricProps) {
|
||||||
|
return (
|
||||||
|
<div className="trace-metric">
|
||||||
|
<div className="trace-kicker">{label}</div>
|
||||||
|
<div className="trace-metric-value">{value}</div>
|
||||||
|
<div className="trace-metric-hint">{hint}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TraceChip({ children, tone = 'default' }: { children: ReactNode; tone?: string }) {
|
||||||
|
return <span className={`trace-chip trace-chip-${tone}`}>{children}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function datumText(item: WorkflowTraceDatum): string {
|
||||||
|
if (typeof item.value === 'string') {
|
||||||
|
return item.value;
|
||||||
|
}
|
||||||
|
if (typeof item.value === 'number' || typeof item.value === 'boolean' || item.value === null) {
|
||||||
|
return String(item.value);
|
||||||
|
}
|
||||||
|
return stringify(item.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function comparableText(value: string): string {
|
||||||
|
return collapseWhitespace(value).toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function TraceDatumCard({ item }: { item: WorkflowTraceDatum }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const value = datumText(item);
|
||||||
|
const isStructured = item.kind === 'json' || typeof item.value === 'object';
|
||||||
|
const preview = item.preview?.trim() ?? '';
|
||||||
|
const hasPreview = preview.length > 0;
|
||||||
|
const valueLabel = isStructured ? 'JSON' : item.kind;
|
||||||
|
const shouldCollapseValue = hasPreview && comparableText(preview) !== comparableText(value);
|
||||||
|
const shouldShowValueOnly = !hasPreview || comparableText(preview) === comparableText(value);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="trace-io-card">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="trace-io-label">{item.label}</div>
|
||||||
|
<TraceChip>{valueLabel}</TraceChip>
|
||||||
|
</div>
|
||||||
|
{hasPreview ? (
|
||||||
|
<div className="trace-io-preview mt-2 text-sm leading-6 text-ink">{preview}</div>
|
||||||
|
) : null}
|
||||||
|
{shouldShowValueOnly ? (
|
||||||
|
isStructured ? (
|
||||||
|
<pre className="trace-io-value mt-2 whitespace-pre-wrap break-words text-xs leading-6 text-muted">
|
||||||
|
{value}
|
||||||
|
</pre>
|
||||||
|
) : (
|
||||||
|
<div className="trace-io-value mt-2 whitespace-pre-wrap break-words text-sm leading-6 text-muted">
|
||||||
|
{value.length > 0 ? value : '-'}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
{shouldCollapseValue ? (
|
||||||
|
<details className="trace-inline-details mt-3">
|
||||||
|
<summary>{isStructured ? t('agentTrace.showFullJson') : t('agentTrace.showFullValue')}</summary>
|
||||||
|
{isStructured ? (
|
||||||
|
<pre className="trace-io-value mt-2 whitespace-pre-wrap break-words text-xs leading-6 text-muted">
|
||||||
|
{value}
|
||||||
|
</pre>
|
||||||
|
) : (
|
||||||
|
<div className="trace-io-value mt-2 whitespace-pre-wrap break-words text-sm leading-6 text-muted">
|
||||||
|
{value.length > 0 ? value : '-'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</details>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TraceFact({ label, value }: { label: string; value: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="trace-fact">
|
||||||
|
<div className="trace-kicker">{label}</div>
|
||||||
|
<div className="mt-1 min-w-0 break-words text-sm leading-6 text-ink">{value || '-'}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailMetaLine({ event }: { event: WorkflowTraceEvent }) {
|
||||||
|
const parts = [
|
||||||
|
`#${event.sequence}`,
|
||||||
|
formatTime(event.timestamp),
|
||||||
|
humanize(event.harness),
|
||||||
|
event.status ? humanize(event.status) : null,
|
||||||
|
].filter(Boolean);
|
||||||
|
return <div className="trace-meta-line">{parts.join(' · ')}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailFactGrid({ event }: { event: WorkflowTraceEvent }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const facts = [
|
||||||
|
{ label: t('agentTrace.iteration'), value: event.iteration ? String(event.iteration) : null },
|
||||||
|
{ label: t('agentTrace.source'), value: event.source },
|
||||||
|
{ label: t('agentTrace.agent'), value: event.agent_name },
|
||||||
|
{ label: t('agentTrace.tool'), value: event.tool_name },
|
||||||
|
{ label: t('agentTrace.backend'), value: event.backend ? humanize(event.backend) : null },
|
||||||
|
].filter((item): item is { label: string; value: string } => Boolean(item.value));
|
||||||
|
|
||||||
|
if (facts.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="trace-debug-summary">
|
||||||
|
{facts.map((fact) => (
|
||||||
|
<TraceFact key={fact.label} label={fact.label} value={fact.value} />
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TraceDataList({
|
||||||
|
title,
|
||||||
|
items,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
items: WorkflowTraceDatum[];
|
||||||
|
}) {
|
||||||
|
if (items.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<section className="trace-explain-block trace-io-section">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="trace-kicker">{title}</div>
|
||||||
|
<TraceChip>{items.length}</TraceChip>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 space-y-3">
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<TraceDatumCard key={`${item.label}-${index}`} item={item} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HarnessDot({ harness }: { harness: string }) {
|
||||||
|
return <span className={`trace-dot trace-dot-${harness}`} aria-hidden="true" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventMatches(event: WorkflowTraceEvent, query: string): boolean {
|
||||||
|
if (!query) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const haystack = [
|
||||||
|
event.harness,
|
||||||
|
event.source,
|
||||||
|
event.title,
|
||||||
|
event.summary,
|
||||||
|
event.decision,
|
||||||
|
event.impact,
|
||||||
|
event.agent_name,
|
||||||
|
event.tool_name,
|
||||||
|
event.backend,
|
||||||
|
event.status,
|
||||||
|
...event.based_on,
|
||||||
|
...(event.inputs ?? []).map((item) => `${item.label}\n${item.preview}\n${datumText(item)}`),
|
||||||
|
...(event.outputs ?? []).map((item) => `${item.label}\n${item.preview}\n${datumText(item)}`),
|
||||||
|
].join('\n').toLowerCase();
|
||||||
|
return haystack.includes(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
function basisItems(event: WorkflowTraceEvent): string[] {
|
||||||
|
return event.based_on.length > 0 ? event.based_on : ['No explicit basis was recorded for this event.'];
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventSubtitle(event: WorkflowTraceEvent): string {
|
||||||
|
const parts = [
|
||||||
|
event.agent_name,
|
||||||
|
event.tool_name,
|
||||||
|
event.backend ? humanize(event.backend) : null,
|
||||||
|
event.iteration ? `Iteration ${event.iteration}` : null,
|
||||||
|
].filter(Boolean);
|
||||||
|
return parts.join(' · ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventSummary(event: WorkflowTraceEvent): string {
|
||||||
|
const summary = event.summary?.trim() ?? '';
|
||||||
|
if (!summary || comparableText(summary) === comparableText(event.title)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
function workflowLogFolderKey(workflow: WorkflowSummary): string {
|
||||||
|
return workflow.log_folder || workflow.log_root || 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
function workflowLogFolderLabel(workflow: WorkflowSummary): string {
|
||||||
|
return workflow.log_folder_label || workflow.log_root_label || 'Unknown log folder';
|
||||||
|
}
|
||||||
|
|
||||||
|
function workflowRunLabel(workflow: WorkflowSummary): string {
|
||||||
|
const started = formatDate(workflow.start_time);
|
||||||
|
const path = workflow.log_relative_path || workflow.path;
|
||||||
|
return `${workflow.task_name} · ${started} · ${path}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AgentTracePage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { workflowId = '' } = useParams();
|
||||||
|
const [workflow, setWorkflow] = useState<WorkflowDetail | null>(null);
|
||||||
|
const [workflows, setWorkflows] = useState<WorkflowSummary[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [activeHarness, setActiveHarness] = useState('all');
|
||||||
|
const [activeLogFolder, setActiveLogFolder] = useState<string | null>(null);
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
const load = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const items = await workflowsApi.listWorkflows();
|
||||||
|
const targetWorkflowId = workflowId === 'latest' ? items[0]?.id : workflowId;
|
||||||
|
if (!targetWorkflowId) {
|
||||||
|
throw new Error(t('agentTrace.noLogRuns'));
|
||||||
|
}
|
||||||
|
const detail = await workflowsApi.getWorkflow(targetWorkflowId);
|
||||||
|
if (!cancelled) {
|
||||||
|
setWorkflows(items);
|
||||||
|
setWorkflow(detail);
|
||||||
|
setSelectedId(detail.trace?.events[0]?.event_id ?? null);
|
||||||
|
setActiveLogFolder((current) => current ?? workflowLogFolderKey(detail));
|
||||||
|
if (workflowId === 'latest') {
|
||||||
|
navigate(`/workflows/${encodeURIComponent(detail.id)}/trace`, { replace: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setError(err instanceof Error ? err.message : t('agentTrace.failedToLoad'));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (workflowId) {
|
||||||
|
void load();
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [navigate, workflowId, t]);
|
||||||
|
|
||||||
|
const trace = workflow?.trace;
|
||||||
|
const events = trace?.events ?? [];
|
||||||
|
const logFolderOptions = useMemo(() => {
|
||||||
|
const folders = new Map<string, { id: string; label: string; count: number }>();
|
||||||
|
for (const item of workflows) {
|
||||||
|
const id = workflowLogFolderKey(item);
|
||||||
|
const existing = folders.get(id);
|
||||||
|
if (existing) {
|
||||||
|
existing.count += 1;
|
||||||
|
} else {
|
||||||
|
folders.set(id, { id, label: workflowLogFolderLabel(item), count: 1 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(folders.values());
|
||||||
|
}, [workflows]);
|
||||||
|
const resolvedActiveLogFolder = activeLogFolder ?? (workflow ? workflowLogFolderKey(workflow) : 'all');
|
||||||
|
const visibleWorkflowOptions = useMemo(() => (
|
||||||
|
resolvedActiveLogFolder === 'all'
|
||||||
|
? workflows
|
||||||
|
: workflows.filter((item) => workflowLogFolderKey(item) === resolvedActiveLogFolder)
|
||||||
|
), [resolvedActiveLogFolder, workflows]);
|
||||||
|
const runWorkflowOptions = useMemo(() => {
|
||||||
|
if (!workflow || visibleWorkflowOptions.some((item) => item.id === workflow.id)) {
|
||||||
|
return visibleWorkflowOptions;
|
||||||
|
}
|
||||||
|
return [workflow, ...visibleWorkflowOptions];
|
||||||
|
}, [visibleWorkflowOptions, workflow]);
|
||||||
|
const harnessEntries = useMemo(
|
||||||
|
() => Object.entries(trace?.summary.harness_counts ?? {}).sort(([, left], [, right]) => right - left),
|
||||||
|
[trace],
|
||||||
|
);
|
||||||
|
const filteredEvents = useMemo(() => {
|
||||||
|
const normalizedQuery = query.trim().toLowerCase();
|
||||||
|
return events.filter((event) => (
|
||||||
|
(activeHarness === 'all' || event.harness === activeHarness) &&
|
||||||
|
eventMatches(event, normalizedQuery)
|
||||||
|
));
|
||||||
|
}, [activeHarness, events, query]);
|
||||||
|
const selectedEvent = useMemo(() => {
|
||||||
|
if (filteredEvents.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return filteredEvents.find((event) => event.event_id === selectedId) ?? filteredEvents[0];
|
||||||
|
}, [filteredEvents, selectedId]);
|
||||||
|
|
||||||
|
const latestWorkflow = workflows[0] ?? workflow;
|
||||||
|
|
||||||
|
const openWorkflowTrace = (nextWorkflowId: string) => {
|
||||||
|
navigate(`/workflows/${encodeURIComponent(nextWorkflowId)}/trace`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogFolderChange = (nextFolder: string) => {
|
||||||
|
setActiveLogFolder(nextFolder);
|
||||||
|
const candidates = nextFolder === 'all'
|
||||||
|
? workflows
|
||||||
|
: workflows.filter((item) => workflowLogFolderKey(item) === nextFolder);
|
||||||
|
const nextWorkflow = candidates[0];
|
||||||
|
if (nextWorkflow && nextWorkflow.id !== workflow?.id) {
|
||||||
|
openWorkflowTrace(nextWorkflow.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="trace-page p-6">
|
||||||
|
<div className="trace-panel p-6 text-sm text-muted">{t('agentTrace.loading')}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !workflow || !trace) {
|
||||||
|
return (
|
||||||
|
<div className="trace-page p-6">
|
||||||
|
<div className="trace-panel p-6 text-sm text-danger">{error ?? t('agentTrace.notFound')}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="trace-page p-6">
|
||||||
|
<div className="mx-auto max-w-[1560px] space-y-5">
|
||||||
|
<section className="trace-hero space-y-5">
|
||||||
|
<div className="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
|
||||||
|
<div className="min-w-0 space-y-3">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Link className="trace-chip" to={`/workflows/${encodeURIComponent(workflow.id)}`}>
|
||||||
|
{t('agentTrace.backToWorkflow')}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="trace-kicker">{workflow.task_id}</div>
|
||||||
|
<h1 className="mt-2 max-w-5xl text-4xl font-semibold leading-[1.05] text-ink lg:text-5xl">
|
||||||
|
{workflow.task_name}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-3 max-w-5xl text-base leading-7 text-muted">
|
||||||
|
{formatInstruction(workflow.instruction, 520, t('format.noInstruction'))}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="trace-panel w-full max-w-md p-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<TraceMetric label={t('agentTrace.started')} value={formatDate(workflow.start_time)} hint={formatDate(workflow.end_time)} />
|
||||||
|
<TraceMetric label={t('agentTrace.status')} value={humanize(workflow.status)} hint={metricHint(workflow.total_steps, 'tool step')} />
|
||||||
|
<TraceMetric label={t('agentTrace.events')} value={trace.summary.total_events} hint={metricHint(trace.summary.iterations.length, 'iteration')} />
|
||||||
|
<TraceMetric label={t('agentTrace.tools')} value={trace.summary.tools.length} hint={metricHint(trace.summary.agents.length, 'agent')} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="trace-panel p-4">
|
||||||
|
<div className="trace-log-grid">
|
||||||
|
<label className="trace-control">
|
||||||
|
<span className="trace-kicker">{t('agentTrace.logFolder')}</span>
|
||||||
|
<select
|
||||||
|
value={resolvedActiveLogFolder}
|
||||||
|
onChange={(event) => handleLogFolderChange(event.target.value)}
|
||||||
|
className="trace-select"
|
||||||
|
>
|
||||||
|
<option value="all">{t('agentTrace.allLogFolders', { count: workflows.length })}</option>
|
||||||
|
{logFolderOptions.map((folder) => (
|
||||||
|
<option key={folder.id} value={folder.id}>
|
||||||
|
{folder.label} · {folder.count}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="trace-control trace-run-control">
|
||||||
|
<span className="trace-kicker">{t('agentTrace.runLog')}</span>
|
||||||
|
<select
|
||||||
|
value={workflow.id}
|
||||||
|
onChange={(event) => openWorkflowTrace(event.target.value)}
|
||||||
|
className="trace-select"
|
||||||
|
>
|
||||||
|
{runWorkflowOptions.map((item) => (
|
||||||
|
<option key={item.id} value={item.id}>
|
||||||
|
{workflowRunLabel(item)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="trace-filter trace-latest-button"
|
||||||
|
disabled={!latestWorkflow}
|
||||||
|
onClick={() => latestWorkflow ? openWorkflowTrace(latestWorkflow.id) : undefined}
|
||||||
|
>
|
||||||
|
{t('agentTrace.latestRun')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="trace-log-path mt-3">
|
||||||
|
<span className="trace-kicker">{t('agentTrace.currentLogPath')}</span>
|
||||||
|
<code>{workflow.log_relative_path || workflow.path}</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="trace-panel p-3">
|
||||||
|
<div className="flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`trace-filter ${activeHarness === 'all' ? 'is-active' : ''}`}
|
||||||
|
onClick={() => setActiveHarness('all')}
|
||||||
|
>
|
||||||
|
{t('agentTrace.allHarnesses')} · {events.length}
|
||||||
|
</button>
|
||||||
|
{harnessEntries.map(([harness, count]) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={harness}
|
||||||
|
className={`trace-filter ${activeHarness === harness ? 'is-active' : ''}`}
|
||||||
|
onClick={() => setActiveHarness(harness)}
|
||||||
|
>
|
||||||
|
<HarnessDot harness={harness} />
|
||||||
|
{humanize(harness)} · {count}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
value={query}
|
||||||
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
|
placeholder={t('agentTrace.searchPlaceholder')}
|
||||||
|
className="trace-search min-w-[320px] px-3 py-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="trace-debug-layout">
|
||||||
|
<div className="trace-panel trace-event-panel min-h-0 overflow-hidden">
|
||||||
|
<div className="border-b border-[color:var(--color-border)] px-4 py-3">
|
||||||
|
<div className="trace-kicker">{t('agentTrace.eventStream')}</div>
|
||||||
|
<div className="mt-1 text-sm text-muted">{t('agentTrace.visibleEvents', { count: filteredEvents.length })}</div>
|
||||||
|
</div>
|
||||||
|
<div className="trace-event-list overflow-auto p-2">
|
||||||
|
{filteredEvents.length === 0 ? (
|
||||||
|
<div className="p-5 text-sm leading-6 text-muted">{t('agentTrace.noEvents')}</div>
|
||||||
|
) : filteredEvents.map((event) => {
|
||||||
|
const isSelected = selectedEvent?.event_id === event.event_id;
|
||||||
|
const summary = eventSummary(event);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={event.event_id}
|
||||||
|
className={`trace-event-row ${isSelected ? 'is-selected' : ''}`}
|
||||||
|
onClick={() => setSelectedId(event.event_id)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="trace-sequence">{event.sequence}</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<HarnessDot harness={event.harness} />
|
||||||
|
<span className="trace-kicker">{humanize(event.harness)}</span>
|
||||||
|
{event.status ? <TraceChip tone={event.status === 'error' ? 'danger' : 'success'}>{humanize(event.status)}</TraceChip> : null}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 truncate text-base font-semibold text-ink">{event.title}</div>
|
||||||
|
<div className="mt-1 text-sm leading-6 text-muted">{eventSubtitle(event) || event.source}</div>
|
||||||
|
{summary ? (
|
||||||
|
<div className="mt-2 text-left text-sm leading-6 text-muted">{truncate(summary, 190)}</div>
|
||||||
|
) : null}
|
||||||
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
|
<TraceChip>{t('agentTrace.ioCount', { inputs: event.inputs?.length ?? 0, outputs: event.outputs?.length ?? 0 })}</TraceChip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 text-xs text-muted">{formatTime(event.timestamp)}</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="trace-panel min-h-0 overflow-hidden">
|
||||||
|
{selectedEvent ? (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div className="border-b border-[color:var(--color-border)] p-5">
|
||||||
|
<DetailMetaLine event={selectedEvent} />
|
||||||
|
<h2 className="mt-3 text-3xl font-semibold leading-tight text-ink">{selectedEvent.title}</h2>
|
||||||
|
{eventSummary(selectedEvent) ? (
|
||||||
|
<p className="mt-3 text-base leading-7 text-muted">{eventSummary(selectedEvent)}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-5 space-y-5">
|
||||||
|
<DetailFactGrid event={selectedEvent} />
|
||||||
|
|
||||||
|
<div className="space-y-5">
|
||||||
|
<TraceDataList
|
||||||
|
title={t('agentTrace.inputs')}
|
||||||
|
items={selectedEvent.inputs ?? []}
|
||||||
|
/>
|
||||||
|
<TraceDataList
|
||||||
|
title={t('agentTrace.outputs')}
|
||||||
|
items={selectedEvent.outputs ?? []}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="trace-reason-grid">
|
||||||
|
<section className="trace-explain-block">
|
||||||
|
<div className="trace-kicker">{t('agentTrace.basedOn')}</div>
|
||||||
|
<ul className="mt-3 space-y-2 text-sm leading-6 text-muted">
|
||||||
|
{basisItems(selectedEvent).map((item, index) => (
|
||||||
|
<li key={`${item}-${index}`}>{item}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
<section className="trace-explain-block">
|
||||||
|
<div className="trace-kicker">{t('agentTrace.decision')}</div>
|
||||||
|
<p className="mt-3 text-sm leading-6 text-muted">{selectedEvent.decision || t('agentTrace.noDecision')}</p>
|
||||||
|
</section>
|
||||||
|
<section className="trace-explain-block">
|
||||||
|
<div className="trace-kicker">{t('agentTrace.impact')}</div>
|
||||||
|
<p className="mt-3 text-sm leading-6 text-muted">{selectedEvent.impact || t('agentTrace.noImpact')}</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details className="trace-details-block">
|
||||||
|
<summary>{t('agentTrace.metadata')}</summary>
|
||||||
|
<pre className="trace-json mt-3 whitespace-pre-wrap break-words text-xs leading-6 text-muted">
|
||||||
|
{stringify(selectedEvent.metadata)}
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details className="trace-details-block">
|
||||||
|
<summary>{t('agentTrace.raw')}</summary>
|
||||||
|
<pre className="trace-json mt-3 whitespace-pre-wrap break-words text-xs leading-6 text-muted">
|
||||||
|
{stringify(selectedEvent.raw)}
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="p-5 text-sm text-muted">{t('agentTrace.noEventSelected')}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
142
apps/dashboard/src/pages/DashboardPage.tsx
Normal file
142
apps/dashboard/src/pages/DashboardPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
671
apps/dashboard/src/pages/EvolutionPage.tsx
Normal file
671
apps/dashboard/src/pages/EvolutionPage.tsx
Normal file
|
|
@ -0,0 +1,671 @@
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import {
|
||||||
|
evolutionApi,
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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);
|
||||||
|
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);
|
||||||
|
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);
|
||||||
|
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);
|
||||||
|
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);
|
||||||
|
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);
|
||||||
|
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);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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>
|
||||||
|
<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 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}
|
||||||
|
{!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>
|
||||||
|
);
|
||||||
|
}
|
||||||
319
apps/dashboard/src/pages/SkillDetailPage.tsx
Normal file
319
apps/dashboard/src/pages/SkillDetailPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
174
apps/dashboard/src/pages/SkillsPage.tsx
Normal file
174
apps/dashboard/src/pages/SkillsPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
792
apps/dashboard/src/pages/WorkflowDetailPage.tsx
Normal file
792
apps/dashboard/src/pages/WorkflowDetailPage.tsx
Normal file
|
|
@ -0,0 +1,792 @@
|
||||||
|
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);
|
||||||
|
const traceEventCount = workflow.trace?.summary.total_events ?? 0;
|
||||||
|
|
||||||
|
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>
|
||||||
|
<Link
|
||||||
|
to={`/workflows/${encodeURIComponent(workflow.id)}/trace`}
|
||||||
|
className="workflow-chip text-sm transition-colors hover:border-[color:var(--color-border-dark)] hover:text-ink"
|
||||||
|
>
|
||||||
|
{t('workflowDetail.openAgentTrace')}
|
||||||
|
</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 })}
|
||||||
|
/>
|
||||||
|
<SummaryMetric
|
||||||
|
label={t('workflowDetail.traceEvents')}
|
||||||
|
value={traceEventCount}
|
||||||
|
hint={t('workflowDetail.traceEventsHint', { count: Object.keys(workflow.trace?.summary.harness_counts ?? {}).length })}
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
130
apps/dashboard/src/pages/WorkflowsPage.tsx
Normal file
130
apps/dashboard/src/pages/WorkflowsPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
85
apps/dashboard/src/utils/diffParser.ts
Normal file
85
apps/dashboard/src/utils/diffParser.ts
Normal 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;
|
||||||
|
}
|
||||||
73
apps/dashboard/src/utils/format.ts
Normal file
73
apps/dashboard/src/utils/format.ts
Normal 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;
|
||||||
|
}
|
||||||
115
apps/dashboard/src/utils/skillClasses.ts
Normal file
115
apps/dashboard/src/utils/skillClasses.ts
Normal 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
1
apps/dashboard/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
/// <reference types="vite/client" />
|
||||||
58
apps/dashboard/tailwind.config.js
Normal file
58
apps/dashboard/tailwind.config.js
Normal 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: [],
|
||||||
|
}
|
||||||
25
apps/dashboard/tsconfig.json
Normal file
25
apps/dashboard/tsconfig.json
Normal 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" }]
|
||||||
|
}
|
||||||
10
apps/dashboard/tsconfig.node.json
Normal file
10
apps/dashboard/tsconfig.node.json
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
35
apps/dashboard/vite.config.ts
Normal file
35
apps/dashboard/vite.config.ts
Normal 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
628
apps/tui/package-lock.json
generated
Normal 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
27
apps/tui/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
48
apps/tui/src/bridge/__tests__/echo_agent.ts
Normal file
48
apps/tui/src/bridge/__tests__/echo_agent.ts
Normal 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(),
|
||||||
|
});
|
||||||
|
}
|
||||||
81
apps/tui/src/bridge/__tests__/scenario_agent.ts
Normal file
81
apps/tui/src/bridge/__tests__/scenario_agent.ts
Normal 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();
|
||||||
128
apps/tui/src/bridge/askUserQuestionState.test.ts
Normal file
128
apps/tui/src/bridge/askUserQuestionState.test.ts
Normal 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",
|
||||||
|
});
|
||||||
|
});
|
||||||
170
apps/tui/src/bridge/askUserQuestionState.ts
Normal file
170
apps/tui/src/bridge/askUserQuestionState.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
25
apps/tui/src/bridge/index.ts
Normal file
25
apps/tui/src/bridge/index.ts
Normal 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";
|
||||||
33
apps/tui/src/bridge/ndjson.ts
Normal file
33
apps/tui/src/bridge/ndjson.ts
Normal 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;
|
||||||
|
}
|
||||||
72
apps/tui/src/bridge/permissionRequestState.test.ts
Normal file
72
apps/tui/src/bridge/permissionRequestState.test.ts
Normal 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");
|
||||||
|
});
|
||||||
145
apps/tui/src/bridge/permissionRequestState.ts
Normal file
145
apps/tui/src/bridge/permissionRequestState.ts
Normal 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,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
173
apps/tui/src/bridge/protocol.test.ts
Normal file
173
apps/tui/src/bridge/protocol.test.ts
Normal file
|
|
@ -0,0 +1,173 @@
|
||||||
|
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("bash_tool_command_executed"), 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,
|
||||||
|
);
|
||||||
|
});
|
||||||
1114
apps/tui/src/bridge/protocol.ts
Normal file
1114
apps/tui/src/bridge/protocol.ts
Normal file
File diff suppressed because it is too large
Load diff
254
apps/tui/src/bridge/structuredIO.test.ts
Normal file
254
apps/tui/src/bridge/structuredIO.test.ts
Normal file
|
|
@ -0,0 +1,254 @@
|
||||||
|
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";
|
||||||
|
import type { IPCMessage, PermissionRequestData } from "./protocol.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("resolved tool permission id can be asked again for edited-input retries", async () => {
|
||||||
|
const output = new PassThrough();
|
||||||
|
const input = new PassThrough();
|
||||||
|
const io = new StructuredIO({ stdin: input, stdout: output });
|
||||||
|
const iterator = io.receive();
|
||||||
|
const requestData: PermissionRequestData = {
|
||||||
|
tool_use_id: "retry-1",
|
||||||
|
tool_name: "bash",
|
||||||
|
tool_input: { command: "echo first" },
|
||||||
|
response_channel: "tool_permission_response",
|
||||||
|
options: [{ option_id: "provide_input", label: "Edit input" }],
|
||||||
|
};
|
||||||
|
const request = {
|
||||||
|
type: "tool_permission_ask",
|
||||||
|
data: requestData,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
} satisfies IPCMessage;
|
||||||
|
|
||||||
|
input.write(`${JSON.stringify(request)}\n`);
|
||||||
|
const first = await iterator.next();
|
||||||
|
assert.equal(first.done, false);
|
||||||
|
assert.equal(first.value.type, "tool_permission_ask");
|
||||||
|
|
||||||
|
const pending = io.waitForPermissionDecision(requestData);
|
||||||
|
io.resolveToolPermission({
|
||||||
|
tool_use_id: "retry-1",
|
||||||
|
option_id: "provide_input",
|
||||||
|
edited_input: { command: "echo second" },
|
||||||
|
});
|
||||||
|
await pending;
|
||||||
|
|
||||||
|
input.write(`${JSON.stringify({
|
||||||
|
...request,
|
||||||
|
data: {
|
||||||
|
...request.data,
|
||||||
|
tool_input: { command: "echo second" },
|
||||||
|
},
|
||||||
|
})}\n`);
|
||||||
|
input.end();
|
||||||
|
|
||||||
|
const second = await iterator.next();
|
||||||
|
assert.equal(second.done, false);
|
||||||
|
assert.equal(second.value.type, "tool_permission_ask");
|
||||||
|
assert.equal((second.value.data as { tool_input: { command: string } }).tool_input.command, "echo second");
|
||||||
|
|
||||||
|
await iterator.return?.();
|
||||||
|
output.destroy();
|
||||||
|
input.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolved tool permission id can be asked again without pending promise", async () => {
|
||||||
|
const output = new PassThrough();
|
||||||
|
const input = new PassThrough();
|
||||||
|
const io = new StructuredIO({ stdin: input, stdout: output });
|
||||||
|
const iterator = io.receive();
|
||||||
|
const request = {
|
||||||
|
type: "tool_permission_ask",
|
||||||
|
data: {
|
||||||
|
tool_use_id: "retry-no-pending",
|
||||||
|
tool_name: "bash",
|
||||||
|
tool_input: { command: "echo first" },
|
||||||
|
response_channel: "tool_permission_response",
|
||||||
|
options: [{ option_id: "provide_input", label: "Edit input" }],
|
||||||
|
},
|
||||||
|
timestamp: Date.now(),
|
||||||
|
} satisfies IPCMessage;
|
||||||
|
|
||||||
|
input.write(`${JSON.stringify(request)}\n`);
|
||||||
|
const first = await iterator.next();
|
||||||
|
assert.equal(first.done, false);
|
||||||
|
assert.equal(first.value.type, "tool_permission_ask");
|
||||||
|
assert.ok(io.seenToolUseIds.has("retry-no-pending"));
|
||||||
|
|
||||||
|
io.resolveToolPermission({
|
||||||
|
tool_use_id: "retry-no-pending",
|
||||||
|
option_id: "provide_input",
|
||||||
|
edited_input: { command: "echo second" },
|
||||||
|
});
|
||||||
|
assert.equal(io.seenToolUseIds.has("retry-no-pending"), false);
|
||||||
|
|
||||||
|
input.write(`${JSON.stringify({
|
||||||
|
...request,
|
||||||
|
data: {
|
||||||
|
...request.data,
|
||||||
|
tool_input: { command: "echo second" },
|
||||||
|
},
|
||||||
|
})}\n`);
|
||||||
|
input.end();
|
||||||
|
|
||||||
|
const second = await iterator.next();
|
||||||
|
assert.equal(second.done, false);
|
||||||
|
assert.equal(second.value.type, "tool_permission_ask");
|
||||||
|
assert.equal((second.value.data as { tool_input: { command: string } }).tool_input.command, "echo second");
|
||||||
|
|
||||||
|
await iterator.return?.();
|
||||||
|
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();
|
||||||
|
});
|
||||||
286
apps/tui/src/bridge/structuredIO.ts
Normal file
286
apps/tui/src/bridge/structuredIO.ts
Normal file
|
|
@ -0,0 +1,286 @@
|
||||||
|
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 with an active prompt already delivered to the UI.
|
||||||
|
* Cleared on resolve/reject so edited-input retry flows can ask again with
|
||||||
|
* the same tool_use_id after Core rechecks permissions.
|
||||||
|
*/
|
||||||
|
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);
|
||||||
|
this.seenToolUseIds.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 {
|
||||||
|
this.seenToolUseIds.delete(response.tool_use_id);
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
79
apps/tui/src/commands/registry.test.ts
Normal file
79
apps/tui/src/commands/registry.test.ts
Normal 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");
|
||||||
|
});
|
||||||
885
apps/tui/src/commands/registry.ts
Normal file
885
apps/tui/src/commands/registry.ts
Normal 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}`;
|
||||||
|
}
|
||||||
216
apps/tui/src/components/AgentEventFeed.tsx
Normal file
216
apps/tui/src/components/AgentEventFeed.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
228
apps/tui/src/components/AgentListPanel.tsx
Normal file
228
apps/tui/src/components/AgentListPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
217
apps/tui/src/components/AgentRuntimePane.tsx
Normal file
217
apps/tui/src/components/AgentRuntimePane.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
228
apps/tui/src/components/AgentTranscriptPanel.tsx
Normal file
228
apps/tui/src/components/AgentTranscriptPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
});
|
||||||
100
apps/tui/src/components/AgentTranscriptPreview.tsx
Normal file
100
apps/tui/src/components/AgentTranscriptPreview.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
97
apps/tui/src/components/App.tsx
Normal file
97
apps/tui/src/components/App.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
79
apps/tui/src/components/BackgroundControlsPanel.tsx
Normal file
79
apps/tui/src/components/BackgroundControlsPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
170
apps/tui/src/components/BackgroundSessionSummary.tsx
Normal file
170
apps/tui/src/components/BackgroundSessionSummary.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
86
apps/tui/src/components/CoordinatorStatusBar.tsx
Normal file
86
apps/tui/src/components/CoordinatorStatusBar.tsx
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
import React from "react";
|
||||||
|
import { Text } from "ink";
|
||||||
|
import type {
|
||||||
|
BackgroundAgentTaskState,
|
||||||
|
CoordinatorRuntimeState,
|
||||||
|
} from "../state/AppStateStore.js";
|
||||||
|
import { getColor } from "./design-system/theme.js";
|
||||||
|
import { useTerminalSize } from "../hooks/useTerminalSize.js";
|
||||||
|
import { truncateToDisplayWidth } from "../utils/textWidth.js";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
coordinator: CoordinatorRuntimeState;
|
||||||
|
backgroundTasks: Record<string, BackgroundAgentTaskState>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const QUIET_TERMINAL_COORDINATOR_STATUSES = new Set([
|
||||||
|
"completed",
|
||||||
|
"complete",
|
||||||
|
"success",
|
||||||
|
"cancelled",
|
||||||
|
"canceled",
|
||||||
|
"stopped",
|
||||||
|
]);
|
||||||
|
|
||||||
|
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 { columns } = useTerminalSize();
|
||||||
|
const derivedRunning = countRunningTeamTasks(
|
||||||
|
backgroundTasks,
|
||||||
|
coordinator.teamName,
|
||||||
|
);
|
||||||
|
const normalizedStatus = coordinator.status?.toLowerCase();
|
||||||
|
const terminalStatus =
|
||||||
|
normalizedStatus !== undefined &&
|
||||||
|
QUIET_TERMINAL_COORDINATOR_STATUSES.has(normalizedStatus);
|
||||||
|
const effectiveDerivedRunning = terminalStatus ? 0 : derivedRunning;
|
||||||
|
const runningWorkers = Math.max(
|
||||||
|
terminalStatus ? 0 : coordinator.runningWorkers,
|
||||||
|
effectiveDerivedRunning,
|
||||||
|
);
|
||||||
|
const totalWorkers = Math.max(
|
||||||
|
coordinator.totalWorkers,
|
||||||
|
Object.values(backgroundTasks).filter(task =>
|
||||||
|
coordinator.teamName ? task.teamName === coordinator.teamName : Boolean(task.teamName),
|
||||||
|
).length,
|
||||||
|
runningWorkers,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!coordinator.teamName && runningWorkers === 0 && totalWorkers === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (runningWorkers === 0 && terminalStatus) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const teamLabel = coordinator.teamName ?? "default";
|
||||||
|
const status = coordinator.status ? ` ${coordinator.status}` : "";
|
||||||
|
const workerSummary =
|
||||||
|
runningWorkers > 0
|
||||||
|
? `${runningWorkers}/${totalWorkers} workers running`
|
||||||
|
: `${totalWorkers} workers tracked`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Text color={getColor("accent")} wrap="truncate">
|
||||||
|
{truncateToDisplayWidth(
|
||||||
|
`Coordinator: ${teamLabel}${status} | ${workerSummary}`,
|
||||||
|
Math.max(20, columns - 1),
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
238
apps/tui/src/components/FullscreenLayout.tsx
Normal file
238
apps/tui/src/components/FullscreenLayout.tsx
Normal 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} width="100%" overflowX="hidden">
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
327
apps/tui/src/components/Message.tsx
Normal file
327
apps/tui/src/components/Message.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
36
apps/tui/src/components/MessageTimestamp.tsx
Normal file
36
apps/tui/src/components/MessageTimestamp.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
616
apps/tui/src/components/Messages.tsx
Normal file
616
apps/tui/src/components/Messages.tsx
Normal file
|
|
@ -0,0 +1,616 @@
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
jumpToMessageId(id: string) {
|
||||||
|
const index = renderableMessages.findIndex(message => message.id === id);
|
||||||
|
if (index < 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
renderableMessages,
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
49
apps/tui/src/components/NotificationBanner.tsx
Normal file
49
apps/tui/src/components/NotificationBanner.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
apps/tui/src/components/PressEnterToContinue.tsx
Normal file
10
apps/tui/src/components/PressEnterToContinue.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
217
apps/tui/src/components/PromptInput/PromptInput.tsx
Normal file
217
apps/tui/src/components/PromptInput/PromptInput.tsx
Normal file
|
|
@ -0,0 +1,217 @@
|
||||||
|
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";
|
||||||
|
import { estimateWrappedRows } from "../../utils/textWidth.js";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
value: string;
|
||||||
|
disabled: boolean;
|
||||||
|
disabledReason?: string;
|
||||||
|
busy?: boolean;
|
||||||
|
inputMode: InputMode;
|
||||||
|
vimMode?: VimMode;
|
||||||
|
cursorOffset?: number;
|
||||||
|
placeholder?: string;
|
||||||
|
suggestions?: CompletionItem[];
|
||||||
|
selectedSuggestion?: number;
|
||||||
|
showSuggestions?: boolean;
|
||||||
|
renderSuggestionsInline?: boolean;
|
||||||
|
publishSuggestionsOverlay?: boolean;
|
||||||
|
maxInputRows?: number;
|
||||||
|
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,
|
||||||
|
maxVisibleChars?: number,
|
||||||
|
): React.ReactElement {
|
||||||
|
const visibleWindow = createVisibleInputWindow(
|
||||||
|
value,
|
||||||
|
cursorOffset,
|
||||||
|
maxVisibleChars,
|
||||||
|
);
|
||||||
|
const boundedOffset = clamp(
|
||||||
|
visibleWindow.cursorOffset,
|
||||||
|
0,
|
||||||
|
visibleWindow.value.length,
|
||||||
|
);
|
||||||
|
const before = visibleWindow.value.slice(0, boundedOffset);
|
||||||
|
const cursor = visibleWindow.value[boundedOffset] ?? " ";
|
||||||
|
const after =
|
||||||
|
boundedOffset < visibleWindow.value.length
|
||||||
|
? visibleWindow.value.slice(boundedOffset + 1)
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Text>
|
||||||
|
<Text>{before}</Text>
|
||||||
|
<Text inverse={!disabled}>{cursor}</Text>
|
||||||
|
<Text>{after}</Text>
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createVisibleInputWindow(
|
||||||
|
value: string,
|
||||||
|
cursorOffset: number,
|
||||||
|
maxVisibleChars: number | undefined,
|
||||||
|
): {
|
||||||
|
value: string;
|
||||||
|
cursorOffset: number;
|
||||||
|
} {
|
||||||
|
if (!maxVisibleChars || value.length <= maxVisibleChars) {
|
||||||
|
return {
|
||||||
|
value,
|
||||||
|
cursorOffset,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const boundedOffset = clamp(cursorOffset, 0, value.length);
|
||||||
|
const windowSize = Math.max(10, maxVisibleChars - 6);
|
||||||
|
const start = clamp(
|
||||||
|
boundedOffset - Math.floor(windowSize * 0.75),
|
||||||
|
0,
|
||||||
|
Math.max(0, value.length - windowSize),
|
||||||
|
);
|
||||||
|
const end = Math.min(value.length, start + windowSize);
|
||||||
|
const prefix = start > 0 ? "..." : "";
|
||||||
|
const suffix = end < value.length ? "..." : "";
|
||||||
|
return {
|
||||||
|
value: `${prefix}${value.slice(start, end)}${suffix}`,
|
||||||
|
cursorOffset: prefix.length + boundedOffset - start,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PromptInput({
|
||||||
|
value,
|
||||||
|
disabled,
|
||||||
|
disabledReason,
|
||||||
|
busy = false,
|
||||||
|
inputMode,
|
||||||
|
vimMode,
|
||||||
|
cursorOffset = value.length,
|
||||||
|
placeholder,
|
||||||
|
suggestions = [],
|
||||||
|
selectedSuggestion = 0,
|
||||||
|
showSuggestions = false,
|
||||||
|
renderSuggestionsInline = true,
|
||||||
|
publishSuggestionsOverlay = true,
|
||||||
|
maxInputRows = 4,
|
||||||
|
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
|
||||||
|
? disabledReason ?? "Input is temporarily unavailable"
|
||||||
|
: "Type a prompt and press Enter");
|
||||||
|
const inputColumns = Math.max(10, terminalSize.columns - 8);
|
||||||
|
const inputRows = value
|
||||||
|
? Math.max(
|
||||||
|
1,
|
||||||
|
Math.min(maxInputRows, estimateWrappedRows(value, inputColumns)),
|
||||||
|
)
|
||||||
|
: 1;
|
||||||
|
const maxVisibleInputChars = value
|
||||||
|
? Math.max(20, inputColumns * maxInputRows - 6)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
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}
|
||||||
|
height={inputRows}
|
||||||
|
overflowY="hidden"
|
||||||
|
>
|
||||||
|
{value ? (
|
||||||
|
renderWithCursor(
|
||||||
|
value,
|
||||||
|
cursorOffset,
|
||||||
|
disabled,
|
||||||
|
maxVisibleInputChars,
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<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}
|
||||||
|
disabledReason={disabledReason}
|
||||||
|
busy={busy}
|
||||||
|
inputMode={inputMode}
|
||||||
|
vimMode={vimMode}
|
||||||
|
suggestionsVisible={showSuggestions}
|
||||||
|
sandbox={sandbox}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
87
apps/tui/src/components/PromptInput/PromptInputFooter.tsx
Normal file
87
apps/tui/src/components/PromptInput/PromptInputFooter.tsx
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
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;
|
||||||
|
disabledReason?: string;
|
||||||
|
busy?: boolean;
|
||||||
|
inputMode: InputMode;
|
||||||
|
vimMode?: VimMode;
|
||||||
|
suggestionsVisible: boolean;
|
||||||
|
sandbox?: SandboxStatusData;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function PromptInputFooter({
|
||||||
|
disabled,
|
||||||
|
disabledReason,
|
||||||
|
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
|
||||||
|
? disabledReason ?? "Input is temporarily unavailable"
|
||||||
|
: 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);
|
||||||
|
|
||||||
|
const vimStatus = vimMode ? `vim ${vimMode}` : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box marginTop={1} width="100%" height={2} flexDirection="column">
|
||||||
|
<Text color="gray" wrap="truncate">{hint}</Text>
|
||||||
|
{sandboxStatus || vimStatus ? (
|
||||||
|
<Text wrap="truncate">
|
||||||
|
{sandboxStatus ? (
|
||||||
|
<Text color={sandboxStatus.color}>{sandboxStatus.text}</Text>
|
||||||
|
) : null}
|
||||||
|
{sandboxStatus && vimStatus ? <Text color="gray"> | </Text> : null}
|
||||||
|
{vimStatus ? <Text color="gray">{vimStatus}</Text> : null}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Text> </Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
121
apps/tui/src/components/PromptInput/SlashCommandComplete.tsx
Normal file
121
apps/tui/src/components/PromptInput/SlashCommandComplete.tsx
Normal 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
|
||||||
|
);
|
||||||
|
}
|
||||||
7
apps/tui/src/components/PromptInput/index.ts
Normal file
7
apps/tui/src/components/PromptInput/index.ts
Normal 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";
|
||||||
5
apps/tui/src/components/PromptInput/inputModes.ts
Normal file
5
apps/tui/src/components/PromptInput/inputModes.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
import type { InputMode } from "../../state/AppStateStore.js";
|
||||||
|
|
||||||
|
export function getModeFromInput(value: string): InputMode {
|
||||||
|
return value.trimStart().startsWith("/") ? "command" : "insert";
|
||||||
|
}
|
||||||
94
apps/tui/src/components/ScrollKeybindingHandler.tsx
Normal file
94
apps/tui/src/components/ScrollKeybindingHandler.tsx
Normal 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;
|
||||||
|
}
|
||||||
43
apps/tui/src/components/Spinner.tsx
Normal file
43
apps/tui/src/components/Spinner.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
81
apps/tui/src/components/StatusBar.tsx
Normal file
81
apps/tui/src/components/StatusBar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
102
apps/tui/src/components/StatusLine.tsx
Normal file
102
apps/tui/src/components/StatusLine.tsx
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
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";
|
||||||
|
import { useTerminalSize } from "../hooks/useTerminalSize.js";
|
||||||
|
import { truncateToDisplayWidth } from "../utils/textWidth.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 { columns } = useTerminalSize();
|
||||||
|
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;
|
||||||
|
const safeColumns = Math.max(20, columns - 1);
|
||||||
|
const primaryText = `OpenSpace | ${runtime.model ?? "model n/a"} | ${runtime.phase ?? "idle"} | Cost ${formatUsd(runtime.costUsd)}`;
|
||||||
|
const secondaryText = [
|
||||||
|
`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}` : null,
|
||||||
|
].filter(Boolean).join(" | ");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column" height={4} width="100%" overflowY="hidden">
|
||||||
|
<Text bold color="cyan" wrap="truncate">
|
||||||
|
{truncateToDisplayWidth(primaryText, safeColumns)}
|
||||||
|
</Text>
|
||||||
|
<Text color="gray" wrap="truncate">
|
||||||
|
{truncateToDisplayWidth(secondaryText, safeColumns)}
|
||||||
|
</Text>
|
||||||
|
<Box height={1}>
|
||||||
|
{showTokenWarning && tokenWarningText ? (
|
||||||
|
<Text color={tokenWarningColor as never} wrap="truncate">
|
||||||
|
{truncateToDisplayWidth(tokenWarningText, safeColumns)}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
<Box height={1} width="100%" overflowX="hidden">
|
||||||
|
{agents ? (
|
||||||
|
<CoordinatorStatusBar
|
||||||
|
coordinator={agents.coordinator}
|
||||||
|
backgroundTasks={agents.backgroundTasks}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
573
apps/tui/src/components/VirtualMessageList.tsx
Normal file
573
apps/tui/src/components/VirtualMessageList.tsx
Normal file
|
|
@ -0,0 +1,573 @@
|
||||||
|
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;
|
||||||
|
jumpToMessageId?: (id: string) => 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
apps/tui/src/components/design-system/Pane.tsx
Normal file
22
apps/tui/src/components/design-system/Pane.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
apps/tui/src/components/design-system/ThemedBox.tsx
Normal file
32
apps/tui/src/components/design-system/ThemedBox.tsx
Normal 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>;
|
||||||
|
}
|
||||||
25
apps/tui/src/components/design-system/ThemedText.tsx
Normal file
25
apps/tui/src/components/design-system/ThemedText.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
apps/tui/src/components/design-system/index.ts
Normal file
15
apps/tui/src/components/design-system/index.ts
Normal 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";
|
||||||
143
apps/tui/src/components/design-system/theme.ts
Normal file
143
apps/tui/src/components/design-system/theme.ts
Normal 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];
|
||||||
|
}
|
||||||
125
apps/tui/src/components/diff/DiffView.tsx
Normal file
125
apps/tui/src/components/diff/DiffView.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue