Maybe Runtime subsystem done
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/target
|
||||
prototype/
|
||||
3443
Cargo.lock
generated
Normal file
3443
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
52
Cargo.toml
Normal file
52
Cargo.toml
Normal file
@@ -0,0 +1,52 @@
|
||||
[package]
|
||||
name = "everjoy"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
# TUI rendering
|
||||
ratatui = "0.29"
|
||||
crossterm = "0.28"
|
||||
tui-textarea = "0.7"
|
||||
tui-term = "0.2"
|
||||
|
||||
# Async + network
|
||||
tokio = { version = "1.4", features = ["full"] }
|
||||
reqwest = { version = "0.12", features = ["json", "stream", "rustls-tls"] }
|
||||
tokio-tungstenite = "0.26"
|
||||
futures = "0.3"
|
||||
|
||||
# Serialization + Agent protocol
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
schemars = "0.8"
|
||||
async-trait = "0.1"
|
||||
|
||||
# Data persistence
|
||||
rusqlite = { version = "0.34", features = ["bundled", "chrono"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
uuid = { version = "1.16", features = ["v4", "serde"] }
|
||||
|
||||
# Text + files
|
||||
ropey = "1.6"
|
||||
pulldown-cmark = "0.13"
|
||||
syntect = "5.2"
|
||||
notify = "8.0"
|
||||
ignore = "0.4"
|
||||
walkdir = "2.5"
|
||||
|
||||
# Config + utilities
|
||||
toml = "0.8"
|
||||
directories = "5.0"
|
||||
regex = "1.11"
|
||||
which = "7.0"
|
||||
tempfile = "3.19"
|
||||
glob = "0.3"
|
||||
|
||||
# Infrastructure
|
||||
anyhow = "1.0"
|
||||
thiserror = "2.0"
|
||||
parking_lot = "0.12"
|
||||
indexmap = "2.7"
|
||||
|
||||
[dev-dependencies]
|
||||
361
TECH_STACK.md
Normal file
361
TECH_STACK.md
Normal file
@@ -0,0 +1,361 @@
|
||||
# Everjoy Tech Stack
|
||||
|
||||
> **Everjoy is not only an IDE but a completely fresh AI workflow.**
|
||||
>
|
||||
> An ideal agent tool with a TUI interacting interface, divided into several workspaces:
|
||||
> file editing, vibe interacting, and project/life management.
|
||||
|
||||
---
|
||||
|
||||
## 1. TUI 渲染与交互框架
|
||||
|
||||
| 库 | 用途 |
|
||||
|----|------|
|
||||
| **ratatui** | TUI 渲染核心,多工作区布局、面板分割、状态管理 |
|
||||
| **crossterm** | 跨平台输入输出(键鼠事件、颜色、光标、屏幕缓冲) |
|
||||
| **tui-textarea** | 编辑器工作区的文本编辑组件 |
|
||||
| **tui-term** | 内置终端面板(Agent 执行命令时展示实时输出) |
|
||||
|
||||
### 为什么选 ratatui
|
||||
|
||||
- 工作区(Workspace)需要复杂布局:侧边栏 + 编辑区 + AI 对话面板 + 状态栏
|
||||
- 支持鼠标点击切换面板、拖拽分割线
|
||||
- 活跃的组件生态,适合快速组装多面板界面
|
||||
|
||||
```bash
|
||||
cargo add ratatui crossterm tui-textarea tui-term
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 异步运行时与网络通信
|
||||
|
||||
| 库 | 用途 |
|
||||
|----|------|
|
||||
| **tokio** | 异步运行时,同时处理 UI 事件、AI 流式响应、文件监听 |
|
||||
| **reqwest** | HTTP 客户端,调用 LLM API(OpenAI、Claude、本地 Ollama 等) |
|
||||
| **tokio-tungstenite** | WebSocket 客户端(实时连接、本地服务通信) |
|
||||
| **futures** | 流处理工具 |
|
||||
|
||||
### AI 流式响应处理
|
||||
|
||||
```bash
|
||||
cargo add tokio --features full
|
||||
cargo add reqwest --features json,stream,rustls-tls
|
||||
cargo add tokio-tungstenite
|
||||
cargo add futures
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Agent 核心引擎
|
||||
|
||||
这是 Everjoy 的灵魂。不依赖现有框架,自研一个轻量但可扩展的 Agent 循环。
|
||||
|
||||
### 核心架构
|
||||
|
||||
```
|
||||
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
||||
│ Observer │ --> │ Thinker │ --> │ Actor │
|
||||
│ (观察环境) │ │ (推理决策) │ │ (执行动作) │
|
||||
└─────────────┘ └─────────────┘ └─────────────┘
|
||||
^ |
|
||||
└────────────── Feedback <─────────────┘
|
||||
```
|
||||
|
||||
### Agent 相关依赖
|
||||
|
||||
| 库 | 用途 |
|
||||
|----|------|
|
||||
| **serde** + **serde_json** | LLM API 请求/响应的结构化序列化 |
|
||||
| **schemars** | 为工具函数生成 JSON Schema,供 LLM function calling 使用 |
|
||||
| **async-trait** | Agent 组件的异步 trait 抽象 |
|
||||
|
||||
```bash
|
||||
cargo add serde --features derive
|
||||
cargo add serde_json
|
||||
cargo add schemars
|
||||
cargo add async-trait
|
||||
```
|
||||
|
||||
### 工具系统(Tools)
|
||||
|
||||
Agent 需要能操作环境的工具集:
|
||||
|
||||
| 工具 | 实现方式 |
|
||||
|------|---------|
|
||||
| 文件读写 | `std::fs` + `ropey`(大文件) |
|
||||
| 命令执行 | `tokio::process::Command` + `tui-term` 展示输出 |
|
||||
| 代码搜索 | `regex` + `ignore`(支持 .gitignore) |
|
||||
| 网络请求 | `reqwest`(Agent 自主查询文档/API) |
|
||||
| 任务管理 | 调用 SQLite 存储层(见第 5 节) |
|
||||
|
||||
```bash
|
||||
cargo add regex ignore
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 记忆系统(Memory)
|
||||
|
||||
Agent 不能金鱼脑。需要短期对话记忆 + 长期知识记忆。
|
||||
|
||||
### 短期记忆:对话上下文
|
||||
|
||||
直接维护 `Vec<Message>`,按 token 限制滑动窗口截断。
|
||||
|
||||
### 长期记忆:向量检索
|
||||
|
||||
| 库 | 用途 |
|
||||
|----|------|
|
||||
| **rusqlite** | 主数据库(对话历史、任务、配置、笔记) |
|
||||
| **sqlite-vec** 或 **pgvector 本地版** | 向量存储与相似度检索 |
|
||||
| **fastembed-rs** | 本地文本 Embedding(不依赖外部 API) |
|
||||
|
||||
> 如果追求极简,可以先只用 SQLite 全文搜索(`fts5`),后期再上向量。
|
||||
|
||||
```bash
|
||||
cargo add rusqlite --features bundled
|
||||
# 向量检索后续按需添加
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 数据持久化层
|
||||
|
||||
Everjoy 管理项目和人生,需要稳定的数据层。
|
||||
|
||||
| 库 | 用途 |
|
||||
|----|------|
|
||||
| **rusqlite** | 嵌入式数据库,存储一切 |
|
||||
| **chrono** | 时间处理、截止日期、日历事件 |
|
||||
| **uuid** | 任务/对话/项目的唯一标识 |
|
||||
|
||||
### 数据库表设计(初版)
|
||||
|
||||
```sql
|
||||
-- 对话历史
|
||||
CREATE TABLE conversations (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT,
|
||||
created_at DATETIME,
|
||||
updated_at DATETIME
|
||||
);
|
||||
|
||||
CREATE TABLE messages (
|
||||
id TEXT PRIMARY KEY,
|
||||
conversation_id TEXT,
|
||||
role TEXT, -- 'user' | 'assistant' | 'system' | 'tool'
|
||||
content TEXT,
|
||||
tool_calls TEXT, -- JSON
|
||||
created_at DATETIME
|
||||
);
|
||||
|
||||
-- 任务/项目管理
|
||||
CREATE TABLE tasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT,
|
||||
description TEXT,
|
||||
status TEXT, -- 'todo' | 'doing' | 'done' | 'archived'
|
||||
priority INTEGER,
|
||||
due_date DATETIME,
|
||||
project TEXT,
|
||||
tags TEXT, -- JSON array
|
||||
created_at DATETIME
|
||||
);
|
||||
|
||||
-- 笔记/知识
|
||||
CREATE TABLE notes (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT,
|
||||
content TEXT,
|
||||
tags TEXT,
|
||||
linked_file TEXT,
|
||||
created_at DATETIME
|
||||
);
|
||||
```
|
||||
|
||||
```bash
|
||||
cargo add chrono --features serde
|
||||
cargo add uuid --features v4,serde
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 内容渲染与解析
|
||||
|
||||
| 库 | 用途 |
|
||||
|----|------|
|
||||
| **pulldown-cmark** | 解析 AI 回复的 Markdown,渲染到 TUI 面板 |
|
||||
| **syntect** | 代码块语法高亮(Markdown 中的 ```rust 块) |
|
||||
| **syntect-tui** | syntect 与 ratatui 的桥接 |
|
||||
|
||||
> AI 回复基本是 Markdown,需要解析成 ratatui 的 `Text`/`Span` 结构渲染。
|
||||
|
||||
```bash
|
||||
cargo add pulldown-cmark
|
||||
cargo add syntect syntect-tui
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 文件系统与项目感知
|
||||
|
||||
| 库 | 用途 |
|
||||
|----|------|
|
||||
| **ropey** | 大文件高效编辑(编辑工作区核心) |
|
||||
| **notify** | 监听文件变化,Agent 实时感知项目改动 |
|
||||
| **ignore** | 按 .gitignore 规则遍历文件树 |
|
||||
| **walkdir** | 目录遍历 |
|
||||
|
||||
```bash
|
||||
cargo add ropey notify ignore walkdir
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 配置管理
|
||||
|
||||
| 库 | 用途 |
|
||||
|----|------|
|
||||
| **toml** | 用户配置(AI API key、主题、快捷键、Agent 人设) |
|
||||
| **serde** | 配置反序列化 |
|
||||
| **directories** | 跨平台定位配置目录 `~/.config/everjoy/` |
|
||||
|
||||
### 配置文件结构
|
||||
|
||||
```toml
|
||||
# ~/.config/everjoy/config.toml
|
||||
[ai]
|
||||
provider = "openai"
|
||||
api_key = "sk-..."
|
||||
model = "gpt-4o"
|
||||
stream = true
|
||||
|
||||
[agent]
|
||||
personality = "你是一位高效且略带幽默的编程伙伴..."
|
||||
auto_execute_tools = false -- 危险操作需确认
|
||||
|
||||
[ui]
|
||||
theme = "everforest"
|
||||
sidebar_width = 30
|
||||
editor_tab_size = 4
|
||||
|
||||
[keymap]
|
||||
"ctrl+space" = "toggle_agent_panel"
|
||||
"ctrl+shift+a" = "ask_agent"
|
||||
```
|
||||
|
||||
```bash
|
||||
cargo add toml serde --features derive directories
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 实用工具库
|
||||
|
||||
| 库 | 用途 |
|
||||
|----|------|
|
||||
| **anyhow** | 快速原型期的错误处理 |
|
||||
| **thiserror** | 后期结构化错误定义 |
|
||||
| **parking_lot** | 更轻量的同步原语 |
|
||||
| **indexmap** | 有序 HashMap(保持面板顺序、配置项顺序) |
|
||||
| **which** | 查找系统命令路径(检查 git、node 等是否存在) |
|
||||
| **tempfile** | 临时文件(Agent 编辑前备份) |
|
||||
| **glob** | 文件通配匹配 |
|
||||
|
||||
```bash
|
||||
cargo add anyhow thiserror parking_lot indexmap which tempfile glob
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 完整 Cargo.toml
|
||||
|
||||
```toml
|
||||
[package]
|
||||
name = "everjoy"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
# TUI 渲染
|
||||
ratatui = "0.29"
|
||||
crossterm = "0.28"
|
||||
tui-textarea = "0.7"
|
||||
tui-term = "0.2"
|
||||
|
||||
# 异步 + 网络
|
||||
tokio = { version = "1.4", features = ["full"] }
|
||||
reqwest = { version = "0.12", features = ["json", "stream", "rustls-tls"] }
|
||||
tokio-tungstenite = "0.26"
|
||||
futures = "0.3"
|
||||
|
||||
# 序列化 + Agent 协议
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
schemars = "0.8"
|
||||
async-trait = "0.1"
|
||||
|
||||
# 数据持久化
|
||||
rusqlite = { version = "0.34", features = ["bundled", "chrono"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
uuid = { version = "1.16", features = ["v4", "serde"] }
|
||||
|
||||
# 文本 + 文件
|
||||
ropey = "1.6"
|
||||
pulldown-cmark = "0.13"
|
||||
syntect = "5.2"
|
||||
# syntect-tui = "..." # 视情况选择或自研桥接
|
||||
notify = "8.0"
|
||||
ignore = "0.4"
|
||||
walkdir = "2.5"
|
||||
|
||||
# 配置 + 工具
|
||||
toml = "0.8"
|
||||
directories = "5.0"
|
||||
regex = "1.11"
|
||||
which = "7.0"
|
||||
tempfile = "3.19"
|
||||
glob = "0.3"
|
||||
|
||||
# 基础设施
|
||||
anyhow = "1.0"
|
||||
thiserror = "2.0"
|
||||
parking_lot = "0.12"
|
||||
indexmap = "2.7"
|
||||
|
||||
[dev-dependencies]
|
||||
# 测试工具后续添加
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. 关键设计决策
|
||||
|
||||
| 维度 | 决策 | 理由 |
|
||||
|------|------|------|
|
||||
| **编辑模式** | **非模态**(直接打字) | 开箱即用,AI 随时可介入 |
|
||||
| **Agent 架构** | **自研 ReAct 循环** | 现有框架太重,自研更灵活 |
|
||||
| **LLM 接入** | **多 Provider 抽象** | 支持 OpenAI/Claude/Ollama/本地模型 |
|
||||
| **流式响应** | **SSE 实时渲染** | AI 思考过程实时展示在对话面板 |
|
||||
| **数据存储** | **SQLite 单文件** | 用户项目 + 人生数据一盘棋 |
|
||||
| **工具执行** | **人工确认 + 自动执行分级** | 读文件自动,写文件/执行命令需确认 |
|
||||
| **文件编辑** | **临时缓冲 + 用户确认写入** | Agent 的修改先预览,用户 OK 再落盘 |
|
||||
|
||||
---
|
||||
|
||||
## 13. 开发里程碑(建议)
|
||||
|
||||
| 阶段 | 目标 | 核心组件 |
|
||||
|------|------|---------|
|
||||
| **MVP** | TUI 多面板 + 基本编辑器 + 能聊天的 Agent | ratatui, tui-textarea, reqwest, 简单 ReAct |
|
||||
| **v0.2** | 文件工具 + 项目内代码问答 | ropey, ignore, notify, 文件读写工具 |
|
||||
| **v0.3** | 任务/笔记管理 + SQLite 持久化 | rusqlite, chrono, 任务面板 |
|
||||
| **v0.4** | 内嵌终端 + Agent 自主执行命令 | tui-term, tokio-process, 命令工具 |
|
||||
| **v0.5** | 记忆系统 + 跨对话上下文 | sqlite-vec, embedding, 长期记忆检索 |
|
||||
|
||||
---
|
||||
|
||||
*for Hoshu Chiu & Everjoy Chiu 🐕*
|
||||
*Just have fun with Everjoy.*
|
||||
218
doc/Architecture-Runtime-and-Plugin.md
Normal file
218
doc/Architecture-Runtime-and-Plugin.md
Normal file
@@ -0,0 +1,218 @@
|
||||
# Everjoy Architecture: Runtime Subsystem + Plugin Subsystem
|
||||
|
||||
## Design Principle
|
||||
|
||||
**Separate concerns:**
|
||||
- **Runtime Subsystem** = the engine. Defines interfaces, manages lifecycle, routes events, schedules rendering. Everything (built-in + external) depends on it.
|
||||
- **Plugin Subsystem** = the adapter. Loads external code, translates C-ABI ↔ Rust trait. Only external modules go through it.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Runtime Subsystem │
|
||||
│ (supporter for everything) │
|
||||
├─────────────────────────────────────────┤
|
||||
│ Module Registry │
|
||||
│ - PanelProvider registry │
|
||||
│ - MenuItem registry │
|
||||
│ - StatusWidget registry │
|
||||
│ - Tool registry │
|
||||
├─────────────────────────────────────────┤
|
||||
│ Lifecycle Manager │
|
||||
│ - init → render → event → hide → │
|
||||
│ show → shutdown │
|
||||
├─────────────────────────────────────────┤
|
||||
│ Event Router │
|
||||
│ - Layer 1: System shortcuts │
|
||||
│ - Layer 2: Focused PanelProvider │
|
||||
│ - Layer 3: Global shortcuts / Menubar │
|
||||
├─────────────────────────────────────────┤
|
||||
│ Render Scheduler │
|
||||
│ - Calls Provider::render() each frame │
|
||||
│ - Blits cell grid → Buffer for │
|
||||
│ external providers │
|
||||
└─────────────────────────────────────────┘
|
||||
↑ ↓ traits
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Plugin Subsystem │
|
||||
│ (loader + translator) │
|
||||
├─────────────────────────────────────────┤
|
||||
│ Extension Loader │
|
||||
│ - Scan plugin directory │
|
||||
│ - Load .wasm / .so / static │
|
||||
│ - Symbol resolution │
|
||||
├─────────────────────────────────────────┤
|
||||
│ Translation Layer │
|
||||
│ - C-ABI ↔ Rust trait │
|
||||
│ - EjSurface memory management │
|
||||
│ - Cell grid → Buffer blitting │
|
||||
│ - Style resolution │
|
||||
│ - Panic isolation │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Runtime Subsystem
|
||||
|
||||
Defines the **trait interfaces** that all modules must satisfy. Built-in modules implement these directly. External modules satisfy them via the Plugin Subsystem.
|
||||
|
||||
### Core Traits
|
||||
|
||||
```rust
|
||||
/// Anything that can render into a panel
|
||||
pub trait PanelProvider: Send {
|
||||
fn init(&mut self, ctx: &mut dyn ProviderCtx);
|
||||
fn render(&mut self, area: Rect, buf: &mut Buffer);
|
||||
fn handle_event(&mut self, event: Event) -> EventResult;
|
||||
fn focus_changed(&mut self, focused: bool);
|
||||
fn hide(&mut self);
|
||||
fn show(&mut self);
|
||||
fn shutdown(&mut self);
|
||||
fn can_open(&self, context: &OpenContext) -> bool;
|
||||
fn keep_alive_on_hide(&self) -> bool { false }
|
||||
}
|
||||
|
||||
/// Callable by Agents
|
||||
pub trait Tool: Send {
|
||||
fn name(&self) -> &str;
|
||||
fn description(&self) -> &str;
|
||||
fn schema(&self) -> ToolSchema; // JSON Schema for LLM function calling
|
||||
fn execute(&self, input: ToolInput) -> ToolOutput;
|
||||
}
|
||||
|
||||
/// Menubar entry
|
||||
pub trait MenuItem: Send {
|
||||
fn id(&self) -> &str;
|
||||
fn label(&self) -> &str;
|
||||
fn parent(&self) -> Option<&str>;
|
||||
fn shortcut(&self) -> Option<&str>;
|
||||
fn invoke(&self);
|
||||
}
|
||||
|
||||
/// Status bar widget
|
||||
pub trait StatusWidget: Send {
|
||||
fn id(&self) -> &str;
|
||||
fn position(&self) -> StatusPosition;
|
||||
fn render(&self, area: Rect, buf: &mut Buffer);
|
||||
}
|
||||
```
|
||||
|
||||
### Module Registry
|
||||
|
||||
```rust
|
||||
pub struct ModuleRegistry {
|
||||
panel_providers: HashMap<String, Box<dyn PanelProvider>>,
|
||||
menu_items: Vec<Box<dyn MenuItem>>,
|
||||
status_widgets: Vec<Box<dyn StatusWidget>>,
|
||||
tools: HashMap<String, Box<dyn Tool>>,
|
||||
}
|
||||
```
|
||||
|
||||
Built-in modules register directly:
|
||||
|
||||
```rust
|
||||
// In everjoy/src/main.rs or a built-in module initializer
|
||||
runtime.register_panel_provider("editor", Box::new(EditorProvider::new()));
|
||||
runtime.register_panel_provider("terminal", Box::new(TerminalProvider::new()));
|
||||
runtime.register_panel_provider("tasktree", Box::new(TaskTreeProvider::new()));
|
||||
runtime.register_tool("read_file", Box::new(ReadFileTool));
|
||||
runtime.register_menu_item(Box::new(QuitMenuItem));
|
||||
```
|
||||
|
||||
### Lifecycle (Runtime-managed)
|
||||
|
||||
```
|
||||
Load (built-in = static init, external = via Plugin Subsystem)
|
||||
↓
|
||||
bootstrap() — register all contributions
|
||||
↓
|
||||
Running — render / event / command loop
|
||||
↓
|
||||
Unload — shutdown() all providers, unregister all contributions
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Plugin Subsystem
|
||||
|
||||
Only responsible for **external modules**. Built-in modules never touch it.
|
||||
|
||||
### Extension Loader
|
||||
|
||||
```rust
|
||||
pub struct ExtensionLoader {
|
||||
plugin_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl ExtensionLoader {
|
||||
pub fn scan_and_load(&self, runtime: &mut Runtime) -> Result<()> {
|
||||
for entry in fs::read_dir(&self.plugin_dir)? {
|
||||
let path = entry?.path();
|
||||
let plugin = self.load(&path)?;
|
||||
plugin.bootstrap(runtime.registrar())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn load(&self, path: &Path) -> Result<Box<dyn Plugin>> {
|
||||
match path.extension() {
|
||||
Some("wasm") => self.load_wasm(path),
|
||||
Some("so") | Some("dll") => self.load_dylib(path),
|
||||
_ => Err("Unknown plugin format"),
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Translation Layer
|
||||
|
||||
For external plugins (especially WASM), the Plugin Subsystem provides a translation layer:
|
||||
|
||||
```rust
|
||||
/// Wraps a C-ABI / WASM plugin into Runtime-compatible traits
|
||||
pub struct TranslatedProvider {
|
||||
wasm_instance: wasmtime::Instance,
|
||||
// ... cached function handles
|
||||
}
|
||||
|
||||
impl PanelProvider for TranslatedProvider {
|
||||
fn render(&mut self, area: Rect, buf: &mut Buffer) {
|
||||
// 1. Allocate EjSurface
|
||||
let mut surface = EjSurface::new(area.width, area.height);
|
||||
// 2. Call plugin's C render function
|
||||
self.call_render(&mut surface);
|
||||
// 3. Blit cell grid into ratatui Buffer
|
||||
self.blit(&surface, area, buf);
|
||||
}
|
||||
// ... other methods translated similarly
|
||||
}
|
||||
```
|
||||
|
||||
### Key Rule
|
||||
|
||||
> **Built-in modules implement Runtime traits directly.**
|
||||
> **External modules are wrapped by Plugin Subsystem to satisfy Runtime traits.**
|
||||
|
||||
```rust
|
||||
// Built-in: direct trait implementation
|
||||
runtime.register_panel_provider("editor", Box::new(EditorProvider::new()));
|
||||
|
||||
// External: loaded via Plugin Subsystem, wrapped to satisfy trait
|
||||
let plugin = plugin_subsystem.load("image-viewer.wasm")?;
|
||||
let provider = plugin.create_provider("image-viewer.main")?;
|
||||
runtime.register_panel_provider("image-viewer", Box::new(provider));
|
||||
// ^^^^ provider here is a TranslatedProvider
|
||||
// that implements PanelProvider
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Concern | Runtime Subsystem | Plugin Subsystem |
|
||||
|---------|-------------------|------------------|
|
||||
| **Defines** | Trait interfaces | Loading + translation mechanism |
|
||||
| **Used by** | Everyone (built-in + external) | Only external modules |
|
||||
| **Manages** | Registry, lifecycle, events, render | File I/O, WASM runtime, FFI |
|
||||
| **Depends on** | ratatui, crossterm, tokio | wasmtime / libloading, Runtime traits |
|
||||
| **Can be omitted?** | No — without it, no app | Yes — without it, only built-in modules work |
|
||||
414
doc/Architecture.md
Normal file
414
doc/Architecture.md
Normal file
@@ -0,0 +1,414 @@
|
||||
# Everjoy Architecture
|
||||
|
||||
## Overview
|
||||
|
||||
Everjoy is a TUI IDE with an AI-native workflow. The architecture separates concerns into four subsystems:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ UI Framework │
|
||||
│ - Layout, focus, render scheduling │
|
||||
│ - Menubar, Status Bar, Splash Screen │
|
||||
│ - Panel management (Sidebar | Main | Agent) │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Runtime Subsystem │
|
||||
│ - Trait definitions (Provider, Tool, Plugin) │
|
||||
│ - Module Registry (providers, tools, menu, status) │
|
||||
│ - Lifecycle management │
|
||||
│ - Command dispatch (Provider → App) │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Agent Framework │
|
||||
│ - Multi-agent orchestration │
|
||||
│ - Role system (Secretary, Driver, Executor...) │
|
||||
│ - Tool invocation via LLM function calling │
|
||||
│ - Memory & context management │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Plugin Subsystem │
|
||||
│ - Extension loader (.wasm / .so / static) │
|
||||
│ - C-ABI ↔ Rust trait translation │
|
||||
│ - Optional — built-in modules don't use it │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. Runtime Subsystem
|
||||
|
||||
The foundation. Defines what modules can do and manages their lifecycle.
|
||||
|
||||
### 1.1 Core Traits
|
||||
|
||||
```rust
|
||||
/// A plugin registers its contributions during bootstrap
|
||||
pub trait Plugin: Send {
|
||||
fn bootstrap(registrar: &mut dyn Registrar);
|
||||
}
|
||||
|
||||
/// Where content comes from (file, URL, memory)
|
||||
pub enum Source {
|
||||
File { path: PathBuf },
|
||||
Url { url: String },
|
||||
Memory { name: String, data: Vec<u8> },
|
||||
}
|
||||
|
||||
/// Actions a Provider can request from the system
|
||||
pub enum Command {
|
||||
/// Present content in a panel. System bids providers on that panel.
|
||||
Present { panel: PanelType, source: Source, mime_hint: Option<String> },
|
||||
|
||||
/// Launch a tool/session by id. Direct dispatch, no bidding.
|
||||
Launch { panel: PanelType, tool_id: String, params: serde_json::Value },
|
||||
|
||||
FocusPanel(PanelType),
|
||||
ShowMessage { msg: String, level: MessageLevel },
|
||||
}
|
||||
|
||||
/// Renders content into a panel
|
||||
pub trait PanelProvider: Send {
|
||||
fn id(&self) -> &str;
|
||||
fn init(&mut self);
|
||||
fn render(&mut self, area: Rect, buf: &mut Buffer);
|
||||
/// Returns (handled?, commands to dispatch)
|
||||
fn handle_event(&mut self, event: Event) -> (EventResult, Vec<Command>);
|
||||
fn focus_changed(&mut self, focused: bool);
|
||||
fn hide(&mut self);
|
||||
fn show(&mut self);
|
||||
fn shutdown(&mut self);
|
||||
/// Score how well this provider handles the source. 0 = cannot handle.
|
||||
fn can_open(&self, source: &Source, mime_hint: Option<&str>) -> i32;
|
||||
fn keep_alive_on_hide(&self) -> bool { false }
|
||||
}
|
||||
|
||||
/// Callable by Agents via LLM function calling
|
||||
pub trait Tool: Send {
|
||||
fn name(&self) -> &str;
|
||||
fn description(&self) -> &str;
|
||||
fn schema(&self) -> ToolSchema;
|
||||
fn execute(&self, input: ToolInput) -> ToolOutput;
|
||||
}
|
||||
```
|
||||
|
||||
### 1.2 Registrar
|
||||
|
||||
```rust
|
||||
pub trait Registrar {
|
||||
fn register_provider(&mut self, desc: ProviderDesc, factory: ProviderFactory);
|
||||
fn register_tool(&mut self, desc: ToolDesc, handler: Box<dyn Tool>);
|
||||
fn register_menu_item(&mut self, desc: MenuItemDesc, handler: Box<dyn Fn() + Send>);
|
||||
fn register_status_widget(&mut self, desc: StatusWidgetDesc, render: Box<dyn Fn(Rect, &mut Buffer) + Send>);
|
||||
}
|
||||
```
|
||||
|
||||
### 1.3 Command Pattern (replaces ProviderCtx)
|
||||
|
||||
**Removed:** `ProviderCtx` trait. Providers no longer call into the system via a context object.
|
||||
|
||||
**New:** Providers return `Vec<Command>` from `handle_event()`. The App (main.rs) is the sole dispatcher.
|
||||
|
||||
Why: `ProviderCtx::open_file()` was an implicit black-box router — the caller didn't know which provider would handle the file, and adding new providers required modifying core code. The Command pattern makes every intent explicit and traceable.
|
||||
|
||||
```rust
|
||||
// Before: implicit, hard-coded routing
|
||||
fn handle_event(&mut self, ev: Event) -> EventResult {
|
||||
self.ctx.open_file("main.rs"); // who handles this? nobody knows
|
||||
Consumed
|
||||
}
|
||||
|
||||
// After: explicit intent, App decides
|
||||
fn handle_event(&mut self, ev: Event) -> (EventResult, Vec<Command>) {
|
||||
let cmd = Command::Present {
|
||||
panel: PanelType::Main,
|
||||
source: Source::File { path: "main.rs".into() },
|
||||
mime_hint: None,
|
||||
};
|
||||
(Consumed, vec![cmd])
|
||||
}
|
||||
```
|
||||
|
||||
### 1.4 Module Registry
|
||||
|
||||
```rust
|
||||
pub struct ModuleRegistry {
|
||||
pub panel_providers: HashMap<String, RegisteredProvider>,
|
||||
pub tools: HashMap<String, Box<dyn Tool>>,
|
||||
pub menu_items: Vec<Box<dyn MenuItem>>,
|
||||
pub status_widgets: Vec<Box<dyn StatusWidget>>,
|
||||
}
|
||||
|
||||
pub struct RegisteredProvider {
|
||||
pub desc: ProviderDesc,
|
||||
pub factory: ProviderFactory,
|
||||
pub instance: Option<Box<dyn PanelProvider>>,
|
||||
}
|
||||
```
|
||||
|
||||
Key methods:
|
||||
- `providers_for_panel(panel) -> Vec<&RegisteredProvider>` — filter by panel type
|
||||
- `ensure_instance(id) -> Option<&mut Box<dyn PanelProvider>>` — lazy instantiate
|
||||
- `find_best_provider(panel, source, mime_hint) -> Option<(String, i32)>` — bid scoring
|
||||
- `shutdown_all()` — graceful teardown
|
||||
|
||||
### 1.5 Lifecycle
|
||||
|
||||
```
|
||||
Load
|
||||
├─ Built-in: registered into ModuleRegistry at App startup
|
||||
└─ External: via Plugin Subsystem
|
||||
↓
|
||||
Bootstrap (Plugin::bootstrap)
|
||||
├─ Register providers (with panel type + capabilities)
|
||||
├─ Register tools
|
||||
├─ Register menu items
|
||||
└─ Register status widgets
|
||||
↓
|
||||
Running
|
||||
├─ Render loop: App asks registry for active provider per panel, calls render()
|
||||
├─ Event loop: App routes to focused provider, receives Commands, dispatches
|
||||
├─ Tool calls: Agent Framework → Tool::execute()
|
||||
└─ Present/Launch: App bids/routes to the best provider
|
||||
↓
|
||||
Unload (atomic)
|
||||
├─ Provider::shutdown() for all active providers
|
||||
├─ Unregister all contributions
|
||||
└─ Plugin::shutdown()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Plugin Subsystem
|
||||
|
||||
Optional subsystem for external modules. Built-in modules implement Runtime traits directly.
|
||||
|
||||
### 2.1 Architecture
|
||||
|
||||
```
|
||||
External Plugin (.wasm / .so)
|
||||
↓
|
||||
Plugin Subsystem
|
||||
├─ Extension Loader: file I/O, symbol resolution
|
||||
└─ Translation Layer
|
||||
├─ C-ABI ↔ Rust trait
|
||||
├─ EjSurface memory management
|
||||
├─ Cell grid → Buffer blitting
|
||||
└─ Panic isolation
|
||||
↓
|
||||
Runtime Subsystem (receives Box<dyn PanelProvider>, Box<dyn Tool>)
|
||||
```
|
||||
|
||||
### 2.2 Key Rule
|
||||
|
||||
> Built-in modules implement Runtime traits directly.
|
||||
> External modules are wrapped by Plugin Subsystem to satisfy Runtime traits.
|
||||
|
||||
---
|
||||
|
||||
## 3. UI Framework
|
||||
|
||||
Manages what the user sees and how they interact.
|
||||
|
||||
### 3.1 Layout
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ _F_ile _E_dit _V_iew _A_gent _H_elp │ Menubar
|
||||
├──────────┬──────────────────────────────┬───────────────────┤
|
||||
│ 📁 src │ 📁 src > main.rs │ 🤖 Agent Chat │
|
||||
│ 📄 Cargo │ [File Editor] │ Claude GPT-4 Local│
|
||||
│ 📄 doc │ │ ───────────────── │
|
||||
│ 📁 targ │ fn main() { │ You: ... │
|
||||
│ │ let x = 42; │ Claude: ... │
|
||||
│ │ } │ │
|
||||
│ │ │ Quick Links: │
|
||||
│ │ │ [agents.md] ... │
|
||||
│ │ │ │
|
||||
├──────────┴──────────────────────────────┴───────────────────┤
|
||||
│ main.rs | Rust | UTF-8 | Ln 3, Col 10 | [Main] │ Status Bar
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **Sidebar** — 20% width, FileTree (navigate files, Enter to open in Main)
|
||||
- **Main Panel** — 52% width, dynamic content (Editor, Terminal, etc.)
|
||||
- **Agent Panel** — 28% width, chat + quick links
|
||||
- **Menubar** — 1 row, underlined capitals for Alt+ shortcuts
|
||||
- **Status Bar** — 1 row, file info + focused panel
|
||||
|
||||
### 3.2 Focus Management
|
||||
|
||||
| Input | Action |
|
||||
|-------|--------|
|
||||
| `Alt` | Focus Menubar |
|
||||
| `Ctrl+J` | Focus Sidebar |
|
||||
| `Ctrl+K` | Focus Main Panel |
|
||||
| `Ctrl+L` | Focus Agent Panel |
|
||||
| Mouse click | Focus clicked panel directly |
|
||||
|
||||
### 3.3 Panel State
|
||||
|
||||
- **Main Panel** — dynamic provider, single content area
|
||||
- **Provider switching** — `hide()` old, `show()` new, old may `keep_alive_on_hide()`
|
||||
- **Navigation** — stack-based history, no tab bar (too heavy for TUI)
|
||||
- **Bidding** — when `Command::Present` arrives, all providers on the target panel score themselves via `can_open()`. Highest score wins.
|
||||
|
||||
### 3.4 Event Routing
|
||||
|
||||
```
|
||||
Layer 1: System shortcuts (Ctrl+Q quit) — intercepted by App
|
||||
Layer 2: Menubar — if active
|
||||
Layer 3: Focus shortcuts (Ctrl+J/K/L) — intercepted by App
|
||||
Layer 4: Focused PanelProvider → returns (EventResult, Vec<Command>)
|
||||
Layer 5: App dispatches returned Commands
|
||||
```
|
||||
|
||||
### 3.5 Render Order
|
||||
|
||||
Per frame:
|
||||
1. Clear
|
||||
2. Menubar
|
||||
3. Panel backgrounds (uniform dark)
|
||||
4. Sidebar Provider::render()
|
||||
5. Main Panel Provider::render()
|
||||
6. Agent Panel Provider::render()
|
||||
7. Status Bar
|
||||
|
||||
---
|
||||
|
||||
## 4. Agent Framework
|
||||
|
||||
### 4.1 Role System
|
||||
|
||||
Managed by core system, not plugins.
|
||||
|
||||
| Role | Icon | Purpose | Trigger |
|
||||
|------|------|---------|---------|
|
||||
| Secretary | 👤 | User conversation | Always |
|
||||
| Driver | 🧭 | Exploration, curiosity | Auto |
|
||||
| NegFeedback | 🛡️ | Error detection | Auto |
|
||||
| Executor | ⚡ | Command execution | Auto |
|
||||
|
||||
### 4.2 Tool Invocation
|
||||
|
||||
Agent → LLM function calling → Tool Registry → Tool::execute() → result → Agent
|
||||
|
||||
Tools are registered by plugins. The Agent Framework queries the Tool Registry for available tools and generates JSON Schema for the LLM.
|
||||
|
||||
---
|
||||
|
||||
## 5. Data Flows
|
||||
|
||||
### 5.1 User Opens a File
|
||||
|
||||
```
|
||||
User navigates to main.rs in Sidebar (FileTree)
|
||||
→ FileTreeProvider::handle_event(Enter)
|
||||
→ Returns Command::Present {
|
||||
panel: Main,
|
||||
source: Source::File { path: "src/main.rs" }
|
||||
}
|
||||
→ App::dispatch_command()
|
||||
→ App asks registry: find_best_provider(Main, File("main.rs"), "text/rust")
|
||||
→ EditorProvider::can_open(File, "text/rust") = 100
|
||||
→ TerminalProvider::can_open(File, ...) = 0
|
||||
→ Editor wins
|
||||
→ App hides old Main provider, shows Editor
|
||||
→ EditorProvider.render()
|
||||
```
|
||||
|
||||
### 5.2 Agent Opens a File
|
||||
|
||||
```
|
||||
Agent decides to open src/main.rs
|
||||
→ LLM function calling: "open_file" tool
|
||||
→ Tool Registry finds "open_file"
|
||||
→ OpenFileTool.execute(path: "src/main.rs")
|
||||
→ Read file from disk
|
||||
→ Returns Command::Present { panel: Main, source: File(...) }
|
||||
→ App dispatches → routes to EditorProvider
|
||||
→ Tool returns "File opened" to Agent
|
||||
```
|
||||
|
||||
### 5.3 Provider Renders a Frame
|
||||
|
||||
```
|
||||
UI Framework render loop
|
||||
→ Determine panel areas from layout
|
||||
→ Look up active provider id per panel from App.active_providers
|
||||
→ Ask registry for instance
|
||||
→ Sidebar area → FileTreeProvider::render(area, buf)
|
||||
→ Main area → EditorProvider::render(area, buf) (or whoever is active)
|
||||
→ Agent area → AgentChatProvider::render(area, buf)
|
||||
→ Draw Menubar and Status Bar
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Directory Structure
|
||||
|
||||
```
|
||||
everjoy/
|
||||
├── src/
|
||||
│ ├── main.rs # Entry point, App event loop, Command dispatch
|
||||
│ ├── lib.rs # Public API
|
||||
│ │
|
||||
│ ├── runtime/ # Runtime Subsystem
|
||||
│ │ ├── mod.rs
|
||||
│ │ ├── traits.rs # Plugin, PanelProvider, Tool, Command, Source
|
||||
│ │ ├── registry.rs # ModuleRegistry, provider bidding
|
||||
│ │ ├── lifecycle.rs # Provider lifecycle management
|
||||
│ │ └── ctx.rs # RuntimeServices (ProviderCtx removed)
|
||||
│ │
|
||||
│ ├── ui/ # UI Framework
|
||||
│ │ ├── mod.rs
|
||||
│ │ ├── layout.rs # Panel sizing
|
||||
│ │ ├── focus.rs # Focus management
|
||||
│ │ ├── router.rs # Event routing (legacy, to be removed)
|
||||
│ │ ├── scheduler.rs # Render scheduling
|
||||
│ │ ├── menubar.rs # Menubar rendering
|
||||
│ │ ├── statusbar.rs # Status bar rendering
|
||||
│ │ └── splash.rs # Splash screen
|
||||
│ │
|
||||
│ ├── agent/ # Agent Framework
|
||||
│ │ ├── mod.rs
|
||||
│ │ ├── roles.rs # Agent role definitions
|
||||
│ │ ├── orchestrator.rs # Multi-agent coordination
|
||||
│ │ ├── memory.rs # Short/long-term memory
|
||||
│ │ └── tools.rs # Tool invocation for LLM
|
||||
│ │
|
||||
│ ├── plugin/ # Plugin Subsystem
|
||||
│ │ ├── mod.rs
|
||||
│ │ ├── loader.rs # ExtensionLoader (.wasm / .so)
|
||||
│ │ ├── translator.rs # C-ABI ↔ Rust trait
|
||||
│ │ ├── surface.rs # EjSurface, cell grid
|
||||
│ │ └── panic.rs # Panic isolation
|
||||
│ │
|
||||
│ └── builtin/ # Built-in modules
|
||||
│ ├── editor/
|
||||
│ ├── terminal/
|
||||
│ ├── tasktree/
|
||||
│ ├── agentchat/
|
||||
│ └── filetree/
|
||||
│
|
||||
├── doc/
|
||||
│ └── Architecture.md # This file
|
||||
│
|
||||
└── Cargo.toml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Key Design Decisions
|
||||
|
||||
| Decision | Choice | Rationale |
|
||||
|----------|--------|-----------|
|
||||
| Provider → System comms | **Command pattern** | Explicit intents, no hidden routing, easy to log/intercept |
|
||||
| Content routing | **Bidding (can_open score)** | Multiple providers can compete; new providers need no core changes |
|
||||
| Provider rendering | Dual-track | Built-in direct Buffer, external cell grid |
|
||||
| Event routing | 5-layer | System → Menubar → Focus shortcuts → Provider → Command dispatch |
|
||||
| Tool lifecycle | Plugin-level | Independent of Provider activation |
|
||||
| Main Panel | Single content | No tab bar, stack nav, keep_alive_on_hide |
|
||||
| Workspaces | Not in MVP | Manual provider switching sufficient |
|
||||
| Plugin loading | Eager + config | All at startup, user-configurable blacklist |
|
||||
| Focus | Keyboard + mouse | Ctrl+J/K/L for panels (avoids terminal tab interception) |
|
||||
|
||||
---
|
||||
|
||||
*Architecture approved? If yes, this becomes the authoritative spec.*
|
||||
157
doc/Plugin-Lifecycle-Tool-vs-Provider.md
Normal file
157
doc/Plugin-Lifecycle-Tool-vs-Provider.md
Normal file
@@ -0,0 +1,157 @@
|
||||
# Tool vs Provider: Different Lifecycles
|
||||
|
||||
## The Problem
|
||||
|
||||
> "How can text plugin receive a tool call when it is not activated to main panel?"
|
||||
|
||||
**Answer: Tool and Provider have different lifecycles.**
|
||||
|
||||
```
|
||||
Plugin (EditorPlugin) ── lives as long as the app runs (or until plugin is unloaded)
|
||||
├── Tool: open_file ── always available
|
||||
├── Tool: save_file ── always available
|
||||
├── Tool: find_and_replace ── always available
|
||||
│
|
||||
└── Provider: EditorProvider ── created/destroyed per Main Panel instance
|
||||
├── Instance A (showing main.rs)
|
||||
├── Instance B (showing lib.rs) ← if we support split later
|
||||
└── destroyed when user closes the file
|
||||
```
|
||||
|
||||
## Key Insight
|
||||
|
||||
**Tool is registered at Plugin level, not Provider level.**
|
||||
|
||||
```rust
|
||||
pub trait Plugin {
|
||||
fn bootstrap(registrar: &mut dyn Registrar);
|
||||
}
|
||||
|
||||
pub trait Registrar {
|
||||
// Tool lives with the Plugin
|
||||
fn register_tool(&mut self, desc: ToolDesc, handler: Box<dyn Tool>);
|
||||
|
||||
// Provider is a factory — created on demand when panel needs it
|
||||
fn register_provider(&mut self, desc: ProviderDesc, factory: ProviderFactory);
|
||||
}
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
### Plugin State (Shared)
|
||||
|
||||
```rust
|
||||
pub struct EditorPlugin {
|
||||
// Plugin-level state: all open documents
|
||||
documents: HashMap<PathBuf, Document>,
|
||||
config: EditorConfig,
|
||||
}
|
||||
|
||||
impl Plugin for EditorPlugin {
|
||||
fn bootstrap(registrar: &mut dyn Registrar) {
|
||||
let plugin = Arc::new(Mutex::new(EditorPlugin::new()));
|
||||
|
||||
// Register tools — these live with the plugin, not any provider instance
|
||||
registrar.register_tool(
|
||||
ToolDesc { name: "open_file", ... },
|
||||
Box::new(OpenFileTool { plugin: plugin.clone() }),
|
||||
);
|
||||
registrar.register_tool(
|
||||
ToolDesc { name: "find_and_replace", ... },
|
||||
Box::new(FindReplaceTool { plugin: plugin.clone() }),
|
||||
);
|
||||
|
||||
// Register provider factory
|
||||
registrar.register_provider(
|
||||
ProviderDesc { id: "editor", panel: PanelType::Main, ... },
|
||||
Box::new(move |_| Box::new(EditorProvider::new(plugin.clone()))),
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Tool Execution (Always Available)
|
||||
|
||||
```rust
|
||||
pub struct OpenFileTool {
|
||||
plugin: Arc<Mutex<EditorPlugin>>,
|
||||
}
|
||||
|
||||
impl Tool for OpenFileTool {
|
||||
fn execute(&self, input: ToolInput) -> ToolOutput {
|
||||
let path = input.args["path"].as_str().unwrap();
|
||||
let content = std::fs::read_to_string(path).unwrap();
|
||||
|
||||
// 1. Store in plugin-level state
|
||||
self.plugin.lock().documents.insert(path.into(), Document::new(content));
|
||||
|
||||
// 2. Request UI to open this file in Main Panel
|
||||
// This goes through a UI action, not the tool system
|
||||
runtime.ui_actions().open_file(path);
|
||||
|
||||
ToolOutput::ok("File opened")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Provider Render (Reads from Shared State)
|
||||
|
||||
```rust
|
||||
pub struct EditorProvider {
|
||||
plugin: Arc<Mutex<EditorPlugin>>,
|
||||
current_file: PathBuf,
|
||||
}
|
||||
|
||||
impl PanelProvider for EditorProvider {
|
||||
fn render(&mut self, area: Rect, buf: &mut Buffer) {
|
||||
let plugin = self.plugin.lock();
|
||||
let doc = plugin.documents.get(&self.current_file).unwrap();
|
||||
// render document content
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Lifecycle Comparison
|
||||
|
||||
| | Tool | Provider |
|
||||
|---|---|---|
|
||||
| **Registered by** | Plugin | Plugin |
|
||||
| **Created when** | Plugin bootstrap | Panel needs to display content |
|
||||
| **Destroyed when** | Plugin unload | Panel switches away (unless keep_alive) |
|
||||
| **Can be called when** | Always | Only when panel is active (render/event) |
|
||||
| **Has state** | Yes, Plugin-level shared state | Yes, instance-level state (cursor, scroll) |
|
||||
|
||||
## Agent Opens a File: Full Flow
|
||||
|
||||
```
|
||||
Agent: "Open src/main.rs"
|
||||
→ LLM decides to call tool "open_file"
|
||||
→ Tool Registry finds "open_file" (registered by EditorPlugin)
|
||||
→ OpenFileTool.execute(path: "src/main.rs")
|
||||
→ Read file from disk
|
||||
→ Store in EditorPlugin.documents
|
||||
→ Call ui_actions.open_file("src/main.rs")
|
||||
→ UI Framework: Main Panel provider = "editor"
|
||||
→ UI Framework: EditorProvider.current_file = "src/main.rs"
|
||||
→ EditorProvider.init()
|
||||
→ EditorProvider.render() — reads from shared state
|
||||
→ Tool returns "File opened" to Agent
|
||||
→ Agent displays "I've opened src/main.rs for you"
|
||||
```
|
||||
|
||||
## Why This Matters
|
||||
|
||||
**Without this separation:**
|
||||
- Agent calls `open_file` → Editor Provider not active → crash or no-op
|
||||
- Tool must wait for Provider to be created → Agent workflow broken
|
||||
|
||||
**With this separation:**
|
||||
- Tool operates on Plugin-level state, independent of UI
|
||||
- UI switches to Provider after Tool completes
|
||||
- Provider renders from shared state
|
||||
|
||||
## Design Rule
|
||||
|
||||
> **Plugin = shared state + tools + provider factory.**
|
||||
> **Tool = operates on shared state, always callable.**
|
||||
> **Provider = renders shared state into a panel, only active when visible.**
|
||||
12
src/agent/memory.rs
Normal file
12
src/agent/memory.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
/// Short-term and long-term memory for agents.
|
||||
pub struct MemorySystem;
|
||||
|
||||
impl MemorySystem {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
// TODO: conversation context sliding window
|
||||
// TODO: vector embedding for long-term retrieval
|
||||
// TODO: SQLite storage
|
||||
}
|
||||
4
src/agent/mod.rs
Normal file
4
src/agent/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub mod memory;
|
||||
pub mod orchestrator;
|
||||
pub mod roles;
|
||||
pub mod tools;
|
||||
12
src/agent/orchestrator.rs
Normal file
12
src/agent/orchestrator.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
/// Coordinates multiple agents and dispatches tasks.
|
||||
pub struct Orchestrator;
|
||||
|
||||
impl Orchestrator {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
// TODO: task decomposition
|
||||
// TODO: agent assignment
|
||||
// TODO: result aggregation
|
||||
}
|
||||
62
src/agent/roles.rs
Normal file
62
src/agent/roles.rs
Normal file
@@ -0,0 +1,62 @@
|
||||
use ratatui::style::Color;
|
||||
|
||||
/// Agent role definition. Managed by core system, not plugins.
|
||||
pub struct AgentRole {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub icon: String,
|
||||
pub color: Color,
|
||||
pub system_prompt: String,
|
||||
pub tools: Vec<String>,
|
||||
pub trigger: TriggerMode,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum TriggerMode {
|
||||
Always,
|
||||
OnUserRequest,
|
||||
OnEvent(String),
|
||||
Auto,
|
||||
}
|
||||
|
||||
/// Built-in roles.
|
||||
pub fn builtin_roles() -> Vec<AgentRole> {
|
||||
vec![
|
||||
AgentRole {
|
||||
id: "secretary".into(),
|
||||
name: "Secretary".into(),
|
||||
icon: "👤".into(),
|
||||
color: Color::Cyan,
|
||||
system_prompt: "You are a helpful assistant...".into(),
|
||||
tools: vec![],
|
||||
trigger: TriggerMode::Always,
|
||||
},
|
||||
AgentRole {
|
||||
id: "driver".into(),
|
||||
name: "Driver".into(),
|
||||
icon: "🧭".into(),
|
||||
color: Color::Yellow,
|
||||
system_prompt: "You are a curious explorer...".into(),
|
||||
tools: vec!["search_web".into(), "read_file".into()],
|
||||
trigger: TriggerMode::Auto,
|
||||
},
|
||||
AgentRole {
|
||||
id: "negfeedback".into(),
|
||||
name: "NegFeedback".into(),
|
||||
icon: "🛡️".into(),
|
||||
color: Color::Red,
|
||||
system_prompt: "You check for errors and potential issues...".into(),
|
||||
tools: vec![],
|
||||
trigger: TriggerMode::Auto,
|
||||
},
|
||||
AgentRole {
|
||||
id: "executor".into(),
|
||||
name: "Executor".into(),
|
||||
icon: "⚡".into(),
|
||||
color: Color::Magenta,
|
||||
system_prompt: "You execute commands and tasks...".into(),
|
||||
tools: vec!["run_shell".into()],
|
||||
trigger: TriggerMode::Auto,
|
||||
},
|
||||
]
|
||||
}
|
||||
15
src/agent/tools.rs
Normal file
15
src/agent/tools.rs
Normal file
@@ -0,0 +1,15 @@
|
||||
use crate::runtime::{ToolInput, ToolOutput};
|
||||
|
||||
/// Invokes tools on behalf of agents via LLM function calling.
|
||||
pub struct ToolInvoker;
|
||||
|
||||
impl ToolInvoker {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
pub fn invoke(&self, _name: &str, _input: ToolInput) -> ToolOutput {
|
||||
// TODO: look up tool in registry, execute, return result
|
||||
ToolOutput::err("not implemented")
|
||||
}
|
||||
}
|
||||
277
src/builtin/agentchat/mod.rs
Normal file
277
src/builtin/agentchat/mod.rs
Normal file
@@ -0,0 +1,277 @@
|
||||
use crate::runtime::{Command, EventResult, PanelProvider, Source};
|
||||
use crossterm::event::{Event, KeyCode, KeyModifiers};
|
||||
use ratatui::{
|
||||
buffer::Buffer,
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
style::{Color, Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Paragraph, Widget, Wrap},
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct ChatMessage {
|
||||
pub(crate) role: String,
|
||||
pub(crate) content: String,
|
||||
}
|
||||
|
||||
pub struct AgentChatProvider {
|
||||
pub(crate) messages: Vec<ChatMessage>,
|
||||
pub(crate) input_buffer: String,
|
||||
cursor_pos: usize,
|
||||
scroll_offset: usize,
|
||||
pub(crate) selected_backend: usize,
|
||||
backends: Vec<String>,
|
||||
}
|
||||
|
||||
impl AgentChatProvider {
|
||||
pub fn new() -> Self {
|
||||
let mut provider = Self {
|
||||
messages: vec![
|
||||
ChatMessage {
|
||||
role: "user".into(),
|
||||
content: "write a sorting function".into(),
|
||||
},
|
||||
ChatMessage {
|
||||
role: "assistant".into(),
|
||||
content: "Here's a clean implementation:\n\nfn sort<T: Ord>(list: &mut [T]) {\n list.sort();\n}\n\nOr if you want a custom quicksort:\n\nfn quicksort<T: Ord>(arr: &mut [T]) {\n if arr.len() <= 1 { return; }\n let pivot = arr.len() - 1;\n // ... partition and recurse\n}".into(),
|
||||
},
|
||||
],
|
||||
input_buffer: String::new(),
|
||||
cursor_pos: 0,
|
||||
scroll_offset: 0,
|
||||
selected_backend: 0,
|
||||
backends: vec!["Claude".into(), "GPT-4".into(), "Local".into()],
|
||||
};
|
||||
provider.scroll_to_bottom();
|
||||
provider
|
||||
}
|
||||
|
||||
fn scroll_to_bottom(&mut self) {
|
||||
self.scroll_offset = self.messages.len().saturating_sub(1);
|
||||
}
|
||||
|
||||
fn add_message(&mut self, role: &str, content: &str) {
|
||||
self.messages.push(ChatMessage {
|
||||
role: role.into(),
|
||||
content: content.into(),
|
||||
});
|
||||
self.scroll_to_bottom();
|
||||
}
|
||||
|
||||
fn handle_input(&mut self, c: char) {
|
||||
self.input_buffer.insert(self.cursor_pos, c);
|
||||
self.cursor_pos += 1;
|
||||
}
|
||||
|
||||
fn handle_backspace(&mut self) {
|
||||
if self.cursor_pos > 0 {
|
||||
self.cursor_pos -= 1;
|
||||
self.input_buffer.remove(self.cursor_pos);
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_delete(&mut self) {
|
||||
if self.cursor_pos < self.input_buffer.len() {
|
||||
self.input_buffer.remove(self.cursor_pos);
|
||||
}
|
||||
}
|
||||
|
||||
fn move_cursor(&mut self, delta: isize) {
|
||||
if delta < 0 {
|
||||
self.cursor_pos = self.cursor_pos.saturating_sub((-delta) as usize);
|
||||
} else {
|
||||
self.cursor_pos = (self.cursor_pos + delta as usize).min(self.input_buffer.len());
|
||||
}
|
||||
}
|
||||
|
||||
fn submit(&mut self) {
|
||||
let text = self.input_buffer.trim().to_string();
|
||||
if !text.is_empty() {
|
||||
self.add_message("user", &text);
|
||||
self.add_message(
|
||||
"assistant",
|
||||
&format!("[{}] Processing: {}...", self.backends[self.selected_backend], text),
|
||||
);
|
||||
}
|
||||
self.input_buffer.clear();
|
||||
self.cursor_pos = 0;
|
||||
}
|
||||
|
||||
fn cycle_backend(&mut self) {
|
||||
self.selected_backend = (self.selected_backend + 1) % self.backends.len();
|
||||
}
|
||||
|
||||
fn message_lines(&self) -> Vec<Line<'_>> {
|
||||
let mut lines = Vec::new();
|
||||
for msg in &self.messages {
|
||||
let (label, label_color) = match msg.role.as_str() {
|
||||
"user" => ("You:", Color::White),
|
||||
"assistant" => ("Agent:", Color::Green),
|
||||
"system" => ("System:", Color::Yellow),
|
||||
_ => ("??:", Color::DarkGray),
|
||||
};
|
||||
lines.push(Line::styled(
|
||||
label.to_string(),
|
||||
Style::default().fg(label_color).add_modifier(Modifier::BOLD),
|
||||
));
|
||||
for content_line in msg.content.lines() {
|
||||
lines.push(Line::styled(
|
||||
content_line.to_string(),
|
||||
Style::default().fg(Color::Rgb(200, 200, 200)),
|
||||
));
|
||||
}
|
||||
lines.push(Line::from(""));
|
||||
}
|
||||
lines
|
||||
}
|
||||
}
|
||||
|
||||
impl PanelProvider for AgentChatProvider {
|
||||
fn as_any(&self) -> &dyn std::any::Any { self }
|
||||
fn as_any_mut(&mut self) -> &mut dyn std::any::Any { self }
|
||||
|
||||
fn id(&self) -> &str { "agentchat" }
|
||||
|
||||
fn render(&mut self, area: Rect, buf: &mut Buffer) {
|
||||
let vertical = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(1),
|
||||
Constraint::Min(0),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(2),
|
||||
Constraint::Length(4),
|
||||
])
|
||||
.split(area);
|
||||
|
||||
let tabs: Vec<Span> = self
|
||||
.backends
|
||||
.iter()
|
||||
.enumerate()
|
||||
.flat_map(|(i, name)| {
|
||||
let style = if i == self.selected_backend {
|
||||
Style::default()
|
||||
.bg(Color::Rgb(60, 60, 60))
|
||||
.fg(Color::Rgb(204, 204, 204))
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(Color::DarkGray)
|
||||
};
|
||||
vec![Span::raw(" "), Span::styled(format!("{} ", name), style)]
|
||||
})
|
||||
.collect();
|
||||
Paragraph::new(Line::from(tabs))
|
||||
.style(Style::default().bg(Color::Rgb(35, 35, 35)))
|
||||
.render(vertical[0], buf);
|
||||
|
||||
let all_lines = self.message_lines();
|
||||
let visible_height = vertical[1].height as usize;
|
||||
let line_count = all_lines.len();
|
||||
|
||||
let scroll_offset = if self.scroll_offset + visible_height > line_count {
|
||||
line_count.saturating_sub(visible_height)
|
||||
} else {
|
||||
self.scroll_offset
|
||||
};
|
||||
|
||||
let visible_lines: Vec<Line> = all_lines
|
||||
.iter()
|
||||
.skip(scroll_offset)
|
||||
.take(visible_height)
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
Paragraph::new(visible_lines)
|
||||
.style(Style::default().bg(Color::Rgb(25, 25, 25)))
|
||||
.wrap(Wrap { trim: false })
|
||||
.render(vertical[1], buf);
|
||||
|
||||
let sep = Block::default()
|
||||
.borders(Borders::TOP)
|
||||
.border_style(Color::Rgb(60, 60, 60));
|
||||
sep.render(vertical[2], buf);
|
||||
|
||||
let input_lines = vec![
|
||||
Line::from(vec![
|
||||
Span::styled(
|
||||
"> ",
|
||||
Style::default().fg(Color::Green).add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::raw(&self.input_buffer),
|
||||
]),
|
||||
Line::styled(
|
||||
"Enter to send | Ctrl+B cycle backend | PgUp/PgDn scroll",
|
||||
Style::default().fg(Color::DarkGray),
|
||||
),
|
||||
];
|
||||
Paragraph::new(input_lines)
|
||||
.style(Style::default().bg(Color::Rgb(25, 25, 25)))
|
||||
.render(vertical[3], buf);
|
||||
|
||||
let links = Paragraph::new(vec![
|
||||
Line::from(""),
|
||||
Line::from(vec![
|
||||
Span::styled("[agents.md]", Style::default().fg(Color::Cyan)),
|
||||
Span::raw(" "),
|
||||
Span::styled("[soul.md]", Style::default().fg(Color::Cyan)),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::styled("[memory.json]", Style::default().fg(Color::Cyan)),
|
||||
Span::raw(" "),
|
||||
Span::styled("[context.md]", Style::default().fg(Color::Cyan)),
|
||||
]),
|
||||
])
|
||||
.block(
|
||||
Block::default()
|
||||
.title(" Quick Links ")
|
||||
.borders(Borders::TOP)
|
||||
.border_style(Color::Rgb(60, 60, 60)),
|
||||
)
|
||||
.style(Style::default().bg(Color::Rgb(25, 25, 25)));
|
||||
links.render(vertical[4], buf);
|
||||
}
|
||||
|
||||
fn handle_event(&mut self, event: Event) -> (EventResult, Vec<Command>) {
|
||||
let commands = Vec::new();
|
||||
if let Event::Key(key) = event {
|
||||
if key.code == KeyCode::Char('b') && key.modifiers.contains(KeyModifiers::CONTROL) {
|
||||
self.cycle_backend();
|
||||
return (EventResult::Consumed, commands);
|
||||
}
|
||||
match key.code {
|
||||
KeyCode::Char(c) => { self.handle_input(c); return (EventResult::Consumed, commands); }
|
||||
KeyCode::Backspace => { self.handle_backspace(); return (EventResult::Consumed, commands); }
|
||||
KeyCode::Delete => { self.handle_delete(); return (EventResult::Consumed, commands); }
|
||||
KeyCode::Enter => { self.submit(); return (EventResult::Consumed, commands); }
|
||||
KeyCode::Left => { self.move_cursor(-1); return (EventResult::Consumed, commands); }
|
||||
KeyCode::Right => { self.move_cursor(1); return (EventResult::Consumed, commands); }
|
||||
KeyCode::Home => { self.cursor_pos = 0; return (EventResult::Consumed, commands); }
|
||||
KeyCode::End => { self.cursor_pos = self.input_buffer.len(); return (EventResult::Consumed, commands); }
|
||||
KeyCode::PageUp => {
|
||||
self.scroll_offset = self.scroll_offset.saturating_sub(5);
|
||||
return (EventResult::Consumed, commands);
|
||||
}
|
||||
KeyCode::PageDown => {
|
||||
let max = self.messages.len().saturating_sub(1);
|
||||
self.scroll_offset = (self.scroll_offset + 5).min(max);
|
||||
return (EventResult::Consumed, commands);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
(EventResult::PassThrough, commands)
|
||||
}
|
||||
|
||||
fn focus_changed(&mut self, _focused: bool) {}
|
||||
fn hide(&mut self) {}
|
||||
fn show(&mut self) {}
|
||||
fn shutdown(&mut self) {}
|
||||
|
||||
fn can_open(&self, _source: &Source, _mime_hint: Option<&str>) -> i32 {
|
||||
// AgentChat doesn't compete for content presentation
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
65
src/builtin/agentchat/tests.rs
Normal file
65
src/builtin/agentchat/tests.rs
Normal file
@@ -0,0 +1,65 @@
|
||||
use crate::builtin::agentchat::AgentChatProvider;
|
||||
use crate::runtime::PanelProvider;
|
||||
use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
|
||||
|
||||
fn char_event(c: char) -> Event {
|
||||
Event::Key(KeyEvent::from(KeyCode::Char(c)))
|
||||
}
|
||||
|
||||
fn key_event(code: KeyCode) -> Event {
|
||||
Event::Key(KeyEvent::from(code))
|
||||
}
|
||||
|
||||
fn ctrl_event(c: char) -> Event {
|
||||
Event::Key(KeyEvent {
|
||||
code: KeyCode::Char(c),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
kind: crossterm::event::KeyEventKind::Press,
|
||||
state: crossterm::event::KeyEventState::empty(),
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agentchat_has_initial_messages() {
|
||||
let chat = AgentChatProvider::new();
|
||||
assert!(!chat.messages.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agentchat_typing() {
|
||||
let mut chat = AgentChatProvider::new();
|
||||
chat.handle_event(char_event('h'));
|
||||
chat.handle_event(char_event('i'));
|
||||
assert_eq!(chat.input_buffer, "hi");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agentchat_submit_adds_message() {
|
||||
let mut chat = AgentChatProvider::new();
|
||||
let before = chat.messages.len();
|
||||
chat.handle_event(char_event('t'));
|
||||
chat.handle_event(char_event('e'));
|
||||
chat.handle_event(char_event('s'));
|
||||
chat.handle_event(char_event('t'));
|
||||
chat.handle_event(key_event(KeyCode::Enter));
|
||||
let after = chat.messages.len();
|
||||
assert!(after > before);
|
||||
assert_eq!(chat.input_buffer, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agentchat_cycle_backend() {
|
||||
let mut chat = AgentChatProvider::new();
|
||||
let first = chat.selected_backend;
|
||||
chat.handle_event(ctrl_event('b'));
|
||||
assert_ne!(chat.selected_backend, first);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agentchat_empty_submit_ignored() {
|
||||
let mut chat = AgentChatProvider::new();
|
||||
let before = chat.messages.len();
|
||||
chat.handle_event(key_event(KeyCode::Enter));
|
||||
let after = chat.messages.len();
|
||||
assert_eq!(after, before);
|
||||
}
|
||||
271
src/builtin/editor/mod.rs
Normal file
271
src/builtin/editor/mod.rs
Normal file
@@ -0,0 +1,271 @@
|
||||
use crate::runtime::{Command, EventResult, PanelProvider, Source};
|
||||
use crossterm::event::{Event, KeyCode, KeyModifiers};
|
||||
use ratatui::{
|
||||
buffer::Buffer,
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
style::{Color, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Paragraph, Widget},
|
||||
};
|
||||
|
||||
pub struct EditorProvider {
|
||||
lines: Vec<String>,
|
||||
cursor_line: usize,
|
||||
cursor_col: usize,
|
||||
scroll_offset: usize,
|
||||
filename: String,
|
||||
modified: bool,
|
||||
}
|
||||
|
||||
impl EditorProvider {
|
||||
pub fn new() -> Self {
|
||||
let code = vec![
|
||||
"// main.rs".into(),
|
||||
"".into(),
|
||||
"fn main() {".into(),
|
||||
" let data = fetch_data();".into(),
|
||||
" process(&data);".into(),
|
||||
" println!(\"done\");".into(),
|
||||
"}".into(),
|
||||
"".into(),
|
||||
"fn fetch_data() -> Vec<i32> {".into(),
|
||||
" vec![1, 2, 3]".into(),
|
||||
"}".into(),
|
||||
"".into(),
|
||||
"fn process(data: &[i32]) {".into(),
|
||||
" let sum: i32 = data.iter().sum();".into(),
|
||||
" println!(\"sum = {}\", sum);".into(),
|
||||
"}".into(),
|
||||
];
|
||||
Self {
|
||||
lines: code,
|
||||
cursor_line: 2,
|
||||
cursor_col: 9,
|
||||
scroll_offset: 0,
|
||||
filename: "main.rs".into(),
|
||||
modified: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cursor_line(&self) -> usize { self.cursor_line + 1 }
|
||||
pub fn cursor_col(&self) -> usize { self.cursor_col + 1 }
|
||||
pub fn filename(&self) -> &str { &self.filename }
|
||||
pub fn is_modified(&self) -> bool { self.modified }
|
||||
|
||||
fn move_cursor(&mut self, dx: isize, dy: isize) {
|
||||
if dy != 0 {
|
||||
let new_line = if dy < 0 {
|
||||
self.cursor_line.saturating_sub((-dy) as usize)
|
||||
} else {
|
||||
(self.cursor_line + dy as usize).min(self.lines.len().saturating_sub(1))
|
||||
};
|
||||
self.cursor_line = new_line;
|
||||
let line_len = self.lines.get(self.cursor_line).map(|l| l.len()).unwrap_or(0);
|
||||
self.cursor_col = self.cursor_col.min(line_len);
|
||||
}
|
||||
if dx != 0 {
|
||||
let line_len = self.lines.get(self.cursor_line).map(|l| l.len()).unwrap_or(0);
|
||||
if dx < 0 {
|
||||
self.cursor_col = self.cursor_col.saturating_sub((-dx) as usize);
|
||||
} else {
|
||||
self.cursor_col = (self.cursor_col + dx as usize).min(line_len);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_cursor_visible(&mut self, visible_height: usize) {
|
||||
if self.cursor_line >= self.scroll_offset + visible_height {
|
||||
self.scroll_offset = self.cursor_line.saturating_sub(visible_height - 1);
|
||||
} else if self.cursor_line < self.scroll_offset {
|
||||
self.scroll_offset = self.cursor_line;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PanelProvider for EditorProvider {
|
||||
fn as_any(&self) -> &dyn std::any::Any { self }
|
||||
fn as_any_mut(&mut self) -> &mut dyn std::any::Any { self }
|
||||
|
||||
fn id(&self) -> &str { "editor" }
|
||||
|
||||
fn render(&mut self, area: Rect, buf: &mut Buffer) {
|
||||
let horizontal = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Length(4), Constraint::Min(0)])
|
||||
.split(area);
|
||||
|
||||
let gutter_area = horizontal[0];
|
||||
let content_area = horizontal[1];
|
||||
let visible_height = content_area.height as usize;
|
||||
|
||||
self.ensure_cursor_visible(visible_height);
|
||||
|
||||
let gutter_lines: Vec<Line> = (self.scroll_offset
|
||||
..(self.scroll_offset + visible_height).min(self.lines.len()))
|
||||
.map(|i| {
|
||||
let num = format!("{:>3} ", i + 1);
|
||||
let style = if i == self.cursor_line {
|
||||
Style::default().fg(Color::Cyan).add_modifier(ratatui::style::Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(Color::DarkGray)
|
||||
};
|
||||
Line::styled(num, style)
|
||||
})
|
||||
.collect();
|
||||
Paragraph::new(gutter_lines).render(gutter_area, buf);
|
||||
|
||||
let content_lines: Vec<Line> = self
|
||||
.lines
|
||||
.iter()
|
||||
.skip(self.scroll_offset)
|
||||
.take(visible_height)
|
||||
.enumerate()
|
||||
.map(|(i, line)| {
|
||||
let absolute_line = self.scroll_offset + i;
|
||||
let is_current = absolute_line == self.cursor_line;
|
||||
let bg = if is_current { Some(Color::Rgb(40, 40, 40)) } else { None };
|
||||
highlight_rust_line(line, bg)
|
||||
})
|
||||
.collect();
|
||||
Paragraph::new(content_lines).render(content_area, buf);
|
||||
}
|
||||
|
||||
fn handle_event(&mut self, event: Event) -> (EventResult, Vec<Command>) {
|
||||
let commands = Vec::new();
|
||||
if let Event::Key(key) = event {
|
||||
match key.code {
|
||||
KeyCode::Up => { self.move_cursor(0, -1); return (EventResult::Consumed, commands); }
|
||||
KeyCode::Down => { self.move_cursor(0, 1); return (EventResult::Consumed, commands); }
|
||||
KeyCode::Left => { self.move_cursor(-1, 0); return (EventResult::Consumed, commands); }
|
||||
KeyCode::Right => { self.move_cursor(1, 0); return (EventResult::Consumed, commands); }
|
||||
KeyCode::Home => { self.cursor_col = 0; return (EventResult::Consumed, commands); }
|
||||
KeyCode::End => {
|
||||
let line_len = self.lines.get(self.cursor_line).map(|l| l.len()).unwrap_or(0);
|
||||
self.cursor_col = line_len;
|
||||
return (EventResult::Consumed, commands);
|
||||
}
|
||||
KeyCode::PageUp => { self.move_cursor(0, -10); return (EventResult::Consumed, commands); }
|
||||
KeyCode::PageDown => { self.move_cursor(0, 10); return (EventResult::Consumed, commands); }
|
||||
KeyCode::Char('g') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
self.cursor_line = 0; self.cursor_col = 0;
|
||||
return (EventResult::Consumed, commands);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
(EventResult::PassThrough, commands)
|
||||
}
|
||||
|
||||
fn focus_changed(&mut self, _focused: bool) {}
|
||||
fn hide(&mut self) {}
|
||||
fn show(&mut self) {}
|
||||
fn shutdown(&mut self) {}
|
||||
|
||||
fn can_open(&self, source: &Source, mime_hint: Option<&str>) -> i32 {
|
||||
let mime = mime_hint
|
||||
.map(|m| m.to_string())
|
||||
.or_else(|| source.mime_hint());
|
||||
match mime.as_deref() {
|
||||
Some("text/rust") | Some("text/plain") | Some("text/toml") | Some("text/markdown")
|
||||
| Some("text/x-shellscript") | Some("text/x-python") | Some("text/javascript")
|
||||
| Some("text/typescript") | Some("application/json") => 100,
|
||||
Some(m) if m.starts_with("text/") => 50,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn highlight_rust_line(line: &str, bg: Option<Color>) -> Line<'_> {
|
||||
let base_style = Style::default().bg(bg.unwrap_or(Color::Reset));
|
||||
if line.trim().starts_with("//") {
|
||||
return Line::styled(line.to_string(), base_style.fg(Color::DarkGray));
|
||||
}
|
||||
|
||||
let keywords = ["fn", "let", "mut", "use", "pub", "struct", "impl", "if", "else", "for", "while", "match", "return", "self", "Self", "where", "async", "await", "trait", "enum", "type", "const", "static", "move", "ref", "dyn"];
|
||||
let types = ["i32", "i64", "u32", "u64", "f32", "f64", "bool", "char", "str", "String", "Vec", "Option", "Result", "Box", "HashMap", "Path", "PathBuf"];
|
||||
let builtins = ["println!", "format!", "vec!", "panic!", "todo!", "unimplemented!", "assert!"];
|
||||
|
||||
let mut spans = Vec::new();
|
||||
let mut remaining = line;
|
||||
|
||||
while !remaining.is_empty() {
|
||||
let mut found = false;
|
||||
|
||||
if remaining.starts_with('"') {
|
||||
if let Some(end) = remaining[1..].find('"') {
|
||||
let lit = &remaining[..end + 2];
|
||||
spans.push(Span::styled(lit.to_string(), base_style.fg(Color::Green)));
|
||||
remaining = &remaining[end + 2..];
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
for kw in &keywords {
|
||||
if remaining.starts_with(kw) {
|
||||
let after = &remaining[kw.len()..];
|
||||
if after.is_empty() || !after.starts_with(|c: char| c.is_alphanumeric() || c == '_') {
|
||||
spans.push(Span::styled(kw.to_string(), base_style.fg(Color::Magenta)));
|
||||
remaining = after;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
for t in &types {
|
||||
if remaining.starts_with(t) {
|
||||
let after = &remaining[t.len()..];
|
||||
if after.is_empty() || !after.starts_with(|c: char| c.is_alphanumeric() || c == '_') {
|
||||
spans.push(Span::styled(t.to_string(), base_style.fg(Color::Yellow)));
|
||||
remaining = after;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
for b in &builtins {
|
||||
if remaining.starts_with(b) {
|
||||
spans.push(Span::styled(b.to_string(), base_style.fg(Color::Cyan)));
|
||||
remaining = &remaining[b.len()..];
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !found && remaining.starts_with(|c: char| c.is_ascii_digit()) {
|
||||
let end = remaining.find(|c: char| !c.is_ascii_digit()).unwrap_or(remaining.len());
|
||||
let num = &remaining[..end];
|
||||
spans.push(Span::styled(num.to_string(), base_style.fg(Color::Yellow)));
|
||||
remaining = &remaining[end..];
|
||||
found = true;
|
||||
}
|
||||
|
||||
if !found {
|
||||
if let Some(i) = remaining.find(|c: char| !c.is_alphanumeric() && c != '_') {
|
||||
if remaining[i..].starts_with('(') && i > 0 {
|
||||
let name = &remaining[..i];
|
||||
spans.push(Span::styled(name.to_string(), base_style.fg(Color::Cyan)));
|
||||
remaining = &remaining[i..];
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
let ch = remaining.chars().next().unwrap();
|
||||
let s = ch.to_string();
|
||||
spans.push(Span::styled(s, base_style.fg(Color::Rgb(204, 204, 204))));
|
||||
remaining = &remaining[ch.len_utf8()..];
|
||||
}
|
||||
}
|
||||
|
||||
Line::from(spans)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
59
src/builtin/editor/tests.rs
Normal file
59
src/builtin/editor/tests.rs
Normal file
@@ -0,0 +1,59 @@
|
||||
use crate::builtin::editor::EditorProvider;
|
||||
use crate::runtime::PanelProvider;
|
||||
use crossterm::event::{Event, KeyCode, KeyEvent};
|
||||
|
||||
fn key_event(code: KeyCode) -> Event {
|
||||
Event::Key(KeyEvent::from(code))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_cursor_moves() {
|
||||
let mut editor = EditorProvider::new();
|
||||
let start_line = editor.cursor_line();
|
||||
let start_col = editor.cursor_col();
|
||||
editor.handle_event(key_event(KeyCode::Down));
|
||||
assert!(editor.cursor_line() > start_line || editor.cursor_line() == start_line);
|
||||
editor.handle_event(key_event(KeyCode::Right));
|
||||
assert!(editor.cursor_col() > start_col || editor.cursor_col() == start_col);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_cursor_bounded() {
|
||||
let mut editor = EditorProvider::new();
|
||||
for _ in 0..100 { editor.handle_event(key_event(KeyCode::Up)); }
|
||||
for _ in 0..100 { editor.handle_event(key_event(KeyCode::Left)); }
|
||||
assert_eq!(editor.cursor_line(), 1);
|
||||
assert_eq!(editor.cursor_col(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_consumes_arrow_keys() {
|
||||
let mut editor = EditorProvider::new();
|
||||
for code in [KeyCode::Up, KeyCode::Down, KeyCode::Left, KeyCode::Right] {
|
||||
let (result, _) = editor.handle_event(key_event(code));
|
||||
assert!(
|
||||
matches!(result, crate::runtime::EventResult::Consumed),
|
||||
"Expected Consumed for {:?}", code
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_has_filename() {
|
||||
let editor = EditorProvider::new();
|
||||
assert_eq!(editor.filename(), "main.rs");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_can_open_text_files() {
|
||||
let editor = EditorProvider::new();
|
||||
let source = crate::runtime::Source::File { path: "test.rs".into() };
|
||||
assert!(editor.can_open(&source, Some("text/rust")) > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_rejects_binary_files() {
|
||||
let editor = EditorProvider::new();
|
||||
let source = crate::runtime::Source::File { path: "image.png".into() };
|
||||
assert_eq!(editor.can_open(&source, Some("image/png")), 0);
|
||||
}
|
||||
304
src/builtin/filetree/mod.rs
Normal file
304
src/builtin/filetree/mod.rs
Normal file
@@ -0,0 +1,304 @@
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::runtime::{Command, EventResult, PanelProvider, Source};
|
||||
use crossterm::event::{Event, KeyCode};
|
||||
use ratatui::{
|
||||
buffer::Buffer,
|
||||
layout::Rect,
|
||||
style::{Color, Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Paragraph, Widget, Wrap},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FileNode
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct FileNode {
|
||||
pub(crate) name: String,
|
||||
pub(crate) path: PathBuf,
|
||||
pub(crate) is_dir: bool,
|
||||
pub(crate) expanded: bool,
|
||||
pub(crate) children: Vec<FileNode>,
|
||||
pub(crate) depth: usize,
|
||||
}
|
||||
|
||||
impl FileNode {
|
||||
fn from_path(path: &Path, depth: usize) -> Option<Self> {
|
||||
let name = path.file_name()?.to_string_lossy().to_string();
|
||||
let is_dir = path.is_dir();
|
||||
Some(Self {
|
||||
name,
|
||||
path: path.to_path_buf(),
|
||||
is_dir,
|
||||
expanded: depth == 0,
|
||||
children: Vec::new(),
|
||||
depth,
|
||||
})
|
||||
}
|
||||
|
||||
fn load_children(&mut self) {
|
||||
if !self.is_dir || !self.children.is_empty() {
|
||||
return;
|
||||
}
|
||||
let Ok(entries) = fs::read_dir(&self.path) else { return };
|
||||
let mut children: Vec<_> = entries
|
||||
.filter_map(|e| e.ok())
|
||||
.filter_map(|e| FileNode::from_path(&e.path(), self.depth + 1))
|
||||
.collect();
|
||||
children.sort_by(|a, b| {
|
||||
match (a.is_dir, b.is_dir) {
|
||||
(true, false) => std::cmp::Ordering::Less,
|
||||
(false, true) => std::cmp::Ordering::Greater,
|
||||
_ => a.name.cmp(&b.name),
|
||||
}
|
||||
});
|
||||
self.children = children;
|
||||
}
|
||||
|
||||
fn toggle(&mut self) {
|
||||
if self.is_dir {
|
||||
self.expanded = !self.expanded;
|
||||
if self.expanded {
|
||||
self.load_children();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FileTreeProvider
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub struct FileTreeProvider {
|
||||
root: FileNode,
|
||||
pub(crate) selected: usize,
|
||||
pub(crate) scroll_offset: usize,
|
||||
}
|
||||
|
||||
impl FileTreeProvider {
|
||||
pub fn new() -> Self {
|
||||
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
|
||||
let mut root = FileNode::from_path(&cwd, 0).unwrap_or_else(|| FileNode {
|
||||
name: ".".into(),
|
||||
path: cwd.clone(),
|
||||
is_dir: true,
|
||||
expanded: true,
|
||||
children: Vec::new(),
|
||||
depth: 0,
|
||||
});
|
||||
root.load_children();
|
||||
Self {
|
||||
root,
|
||||
selected: 0,
|
||||
scroll_offset: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn flat_nodes(&self) -> Vec<&FileNode> {
|
||||
let mut result = Vec::new();
|
||||
Self::collect_visible(&self.root, &mut result);
|
||||
result
|
||||
}
|
||||
|
||||
fn collect_visible<'a>(node: &'a FileNode, out: &mut Vec<&'a FileNode>) {
|
||||
out.push(node);
|
||||
if node.expanded {
|
||||
for child in &node.children {
|
||||
Self::collect_visible(child, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn node_by_index_mut(&mut self, idx: usize) -> Option<&mut FileNode> {
|
||||
let mut stack = vec![&mut self.root];
|
||||
let mut count = 0;
|
||||
while let Some(node) = stack.pop() {
|
||||
if count == idx {
|
||||
return Some(node);
|
||||
}
|
||||
count += 1;
|
||||
if node.expanded {
|
||||
for child in node.children.iter_mut().rev() {
|
||||
stack.push(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn move_selection(&mut self, delta: isize) {
|
||||
let flat = self.flat_nodes();
|
||||
if flat.is_empty() {
|
||||
return;
|
||||
}
|
||||
let new_idx = if delta < 0 {
|
||||
self.selected.saturating_sub((-delta) as usize)
|
||||
} else {
|
||||
(self.selected + delta as usize).min(flat.len().saturating_sub(1))
|
||||
};
|
||||
self.selected = new_idx;
|
||||
}
|
||||
}
|
||||
|
||||
impl PanelProvider for FileTreeProvider {
|
||||
fn as_any(&self) -> &dyn std::any::Any { self }
|
||||
fn as_any_mut(&mut self) -> &mut dyn std::any::Any { self }
|
||||
|
||||
fn id(&self) -> &str { "filetree" }
|
||||
|
||||
fn render(&mut self, area: Rect, buf: &mut Buffer) {
|
||||
let visible_height = area.height as usize;
|
||||
|
||||
self.scroll_offset = if self.selected >= self.scroll_offset + visible_height {
|
||||
self.selected.saturating_sub(visible_height - 1)
|
||||
} else if self.selected < self.scroll_offset {
|
||||
self.selected
|
||||
} else {
|
||||
self.scroll_offset
|
||||
};
|
||||
|
||||
let flat = self.flat_nodes();
|
||||
let lines: Vec<Line> = flat
|
||||
.iter()
|
||||
.skip(self.scroll_offset)
|
||||
.take(visible_height)
|
||||
.enumerate()
|
||||
.map(|(i, node)| {
|
||||
let absolute_idx = self.scroll_offset + i;
|
||||
let is_selected = absolute_idx == self.selected;
|
||||
|
||||
let indent = " ".repeat(node.depth);
|
||||
let prefix = if node.is_dir {
|
||||
if node.expanded { "📂 " } else { "📁 " }
|
||||
} else {
|
||||
" "
|
||||
};
|
||||
let icon = if node.is_dir { "" } else { file_icon(&node.name) };
|
||||
|
||||
let name_style = if is_selected {
|
||||
Style::default()
|
||||
.fg(Color::Black)
|
||||
.bg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else if node.is_dir {
|
||||
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(Color::Rgb(204, 204, 204))
|
||||
};
|
||||
|
||||
Line::from(vec![
|
||||
Span::styled(indent, Style::default().fg(Color::DarkGray)),
|
||||
Span::raw(prefix),
|
||||
Span::styled(format!("{}{}", icon, node.name), name_style),
|
||||
])
|
||||
})
|
||||
.collect();
|
||||
|
||||
let title = format!(" 📁 {} ", self.root.name);
|
||||
let block = ratatui::widgets::Block::default()
|
||||
.title(title)
|
||||
.borders(ratatui::widgets::Borders::NONE);
|
||||
|
||||
Paragraph::new(lines)
|
||||
.block(block)
|
||||
.wrap(Wrap { trim: false })
|
||||
.render(area, buf);
|
||||
}
|
||||
|
||||
fn handle_event(&mut self, event: Event) -> (EventResult, Vec<Command>) {
|
||||
let mut commands = Vec::new();
|
||||
if let Event::Key(key) = event {
|
||||
match key.code {
|
||||
KeyCode::Up => {
|
||||
self.move_selection(-1);
|
||||
return (EventResult::Consumed, commands);
|
||||
}
|
||||
KeyCode::Down => {
|
||||
self.move_selection(1);
|
||||
return (EventResult::Consumed, commands);
|
||||
}
|
||||
KeyCode::Left => {
|
||||
let parent_path = self.node_by_index_mut(self.selected)
|
||||
.filter(|n| n.depth > 0 && !(n.is_dir && n.expanded))
|
||||
.map(|n| n.path.parent().map(|p| p.to_path_buf()));
|
||||
|
||||
if let Some(Some(parent)) = parent_path {
|
||||
let flat = self.flat_nodes();
|
||||
for (i, n) in flat.iter().enumerate() {
|
||||
if n.path == parent {
|
||||
self.selected = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else if let Some(node) = self.node_by_index_mut(self.selected) {
|
||||
if node.is_dir && node.expanded {
|
||||
node.toggle();
|
||||
}
|
||||
}
|
||||
return (EventResult::Consumed, commands);
|
||||
}
|
||||
KeyCode::Right | KeyCode::Enter => {
|
||||
if let Some(node) = self.node_by_index_mut(self.selected) {
|
||||
if node.is_dir {
|
||||
node.toggle();
|
||||
} else {
|
||||
// Emit a command to open this file in Main panel
|
||||
commands.push(Command::Present {
|
||||
panel: crate::runtime::PanelType::Main,
|
||||
source: Source::File { path: node.path.clone() },
|
||||
mime_hint: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
return (EventResult::Consumed, commands);
|
||||
}
|
||||
KeyCode::Char(' ') => {
|
||||
if let Some(node) = self.node_by_index_mut(self.selected) {
|
||||
if node.is_dir {
|
||||
node.toggle();
|
||||
}
|
||||
}
|
||||
return (EventResult::Consumed, commands);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
(EventResult::PassThrough, commands)
|
||||
}
|
||||
|
||||
fn focus_changed(&mut self, _focused: bool) {}
|
||||
fn hide(&mut self) {}
|
||||
fn show(&mut self) {}
|
||||
fn shutdown(&mut self) {}
|
||||
|
||||
fn can_open(&self, _source: &Source, _mime_hint: Option<&str>) -> i32 {
|
||||
// FileTree doesn't compete for content; it's always the sidebar navigator
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
fn file_icon(name: &str) -> &'static str {
|
||||
if name.ends_with(".rs") {
|
||||
"🦀 "
|
||||
} else if name.ends_with(".toml") || name.ends_with(".lock") {
|
||||
"⚙️ "
|
||||
} else if name.ends_with(".md") {
|
||||
"📝 "
|
||||
} else if name.ends_with(".json") {
|
||||
"📋 "
|
||||
} else if name.ends_with(".sh") || name.ends_with(".bash") {
|
||||
"🐚 "
|
||||
} else if name.ends_with(".py") {
|
||||
"🐍 "
|
||||
} else if name.ends_with(".js") || name.ends_with(".ts") {
|
||||
"📜 "
|
||||
} else {
|
||||
"📄 "
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
71
src/builtin/filetree/tests.rs
Normal file
71
src/builtin/filetree/tests.rs
Normal file
@@ -0,0 +1,71 @@
|
||||
use crate::builtin::filetree::FileTreeProvider;
|
||||
use crate::runtime::PanelProvider;
|
||||
use crossterm::event::{Event, KeyCode, KeyEvent};
|
||||
|
||||
fn key_event(code: KeyCode) -> Event {
|
||||
Event::Key(KeyEvent::from(code))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filetree_starts_at_root() {
|
||||
let tree = FileTreeProvider::new();
|
||||
let flat = tree.flat_nodes();
|
||||
assert!(!flat.is_empty());
|
||||
assert_eq!(flat[0].depth, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filetree_navigate_down() {
|
||||
let mut tree = FileTreeProvider::new();
|
||||
let before = tree.flat_nodes().len();
|
||||
assert!(before > 0);
|
||||
tree.handle_event(key_event(KeyCode::Down));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filetree_expand_collapse() {
|
||||
let mut tree = FileTreeProvider::new();
|
||||
let flat = tree.flat_nodes();
|
||||
let first_dir_idx = flat.iter().position(|n| n.is_dir).unwrap_or(0);
|
||||
for _ in 0..first_dir_idx {
|
||||
tree.handle_event(key_event(KeyCode::Down));
|
||||
}
|
||||
let before = tree.flat_nodes().len();
|
||||
tree.handle_event(key_event(KeyCode::Left));
|
||||
let after_collapse = tree.flat_nodes().len();
|
||||
tree.handle_event(key_event(KeyCode::Right));
|
||||
let after_expand = tree.flat_nodes().len();
|
||||
assert!(after_expand >= after_collapse);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filetree_consumes_navigation_keys() {
|
||||
let mut tree = FileTreeProvider::new();
|
||||
let (result, _) = tree.handle_event(key_event(KeyCode::Down));
|
||||
assert!(matches!(result, crate::runtime::EventResult::Consumed));
|
||||
let (result, _) = tree.handle_event(key_event(KeyCode::Up));
|
||||
assert!(matches!(result, crate::runtime::EventResult::Consumed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filetree_passes_unknown_keys() {
|
||||
let mut tree = FileTreeProvider::new();
|
||||
let (result, _) = tree.handle_event(key_event(KeyCode::Char('x')));
|
||||
assert!(matches!(result, crate::runtime::EventResult::PassThrough));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filetree_enter_emits_present_command() {
|
||||
let mut tree = FileTreeProvider::new();
|
||||
// Move to first file (not directory)
|
||||
let flat = tree.flat_nodes();
|
||||
let first_file_idx = flat.iter().position(|n| !n.is_dir);
|
||||
if let Some(idx) = first_file_idx {
|
||||
for _ in 0..idx {
|
||||
tree.handle_event(key_event(KeyCode::Down));
|
||||
}
|
||||
let (_, commands) = tree.handle_event(key_event(KeyCode::Enter));
|
||||
assert!(!commands.is_empty());
|
||||
assert!(matches!(&commands[0], crate::runtime::Command::Present { .. }));
|
||||
}
|
||||
}
|
||||
5
src/builtin/mod.rs
Normal file
5
src/builtin/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
pub mod agentchat;
|
||||
pub mod editor;
|
||||
pub mod filetree;
|
||||
pub mod tasktree;
|
||||
pub mod terminal;
|
||||
83
src/builtin/tasktree/mod.rs
Normal file
83
src/builtin/tasktree/mod.rs
Normal file
@@ -0,0 +1,83 @@
|
||||
use crate::runtime::{Command, EventResult, PanelProvider, Source};
|
||||
use crossterm::event::Event;
|
||||
use ratatui::{
|
||||
buffer::Buffer,
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
style::{Color, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Paragraph, Widget, Wrap},
|
||||
};
|
||||
|
||||
pub struct TaskTreeProvider;
|
||||
|
||||
impl TaskTreeProvider {
|
||||
pub fn new() -> Self { Self }
|
||||
}
|
||||
|
||||
impl PanelProvider for TaskTreeProvider {
|
||||
fn as_any(&self) -> &dyn std::any::Any { self }
|
||||
fn as_any_mut(&mut self) -> &mut dyn std::any::Any { self }
|
||||
|
||||
fn id(&self) -> &str { "tasktree" }
|
||||
|
||||
fn render(&mut self, area: Rect, buf: &mut Buffer) {
|
||||
let vertical = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Length(1), Constraint::Min(0)])
|
||||
.split(area);
|
||||
|
||||
let tabs = Paragraph::new("[Tree] [Chain] [By Agent]")
|
||||
.style(Style::default().fg(Color::DarkGray));
|
||||
tabs.render(vertical[0], buf);
|
||||
|
||||
let content = Paragraph::new(vec![
|
||||
Line::from(""),
|
||||
agent_line("👤", "Secretary", Color::Cyan, "idle", Color::Green),
|
||||
task_line(" └─", "✅", Color::Green, "Explain API to user"),
|
||||
Line::from(""),
|
||||
agent_line("🧭", "Driver", Color::Yellow, "work", Color::Yellow),
|
||||
task_line(" ├─", "🟡", Color::Yellow, "Research async patterns"),
|
||||
task_line(" └─", "⚪", Color::DarkGray, "Check error handling"),
|
||||
Line::from(""),
|
||||
agent_line("🛡️", "NegFeedback", Color::Red, "idle", Color::Green),
|
||||
task_line(" └─", "✅", Color::Green, "Flag race condition"),
|
||||
Line::from(""),
|
||||
agent_line("⚡", "Executor", Color::Magenta, "work", Color::Yellow),
|
||||
task_line(" └─", "🟡", Color::Yellow, "cargo test..."),
|
||||
Line::from(""),
|
||||
Line::from("3 idle | 2 work").style(Style::default().fg(Color::DarkGray)),
|
||||
])
|
||||
.wrap(Wrap { trim: true });
|
||||
|
||||
content.render(vertical[1], buf);
|
||||
}
|
||||
|
||||
fn handle_event(&mut self, _event: Event) -> (EventResult, Vec<Command>) {
|
||||
(EventResult::PassThrough, Vec::new())
|
||||
}
|
||||
|
||||
fn focus_changed(&mut self, _focused: bool) {}
|
||||
fn hide(&mut self) {}
|
||||
fn show(&mut self) {}
|
||||
fn shutdown(&mut self) {}
|
||||
|
||||
fn can_open(&self, _source: &Source, _mime_hint: Option<&str>) -> i32 { 0 }
|
||||
}
|
||||
|
||||
fn agent_line<'a>(icon: &'a str, name: &'a str, name_color: Color, status: &'a str, status_color: Color) -> Line<'a> {
|
||||
Line::from(vec![
|
||||
Span::raw(icon),
|
||||
Span::raw(" "),
|
||||
Span::styled(name, Style::default().fg(name_color).add_modifier(ratatui::style::Modifier::BOLD)),
|
||||
Span::raw(" "),
|
||||
Span::styled(format!("● {}", status), Style::default().fg(status_color)),
|
||||
])
|
||||
}
|
||||
|
||||
fn task_line<'a>(indent: &'a str, icon: &'a str, icon_color: Color, text: &'a str) -> Line<'a> {
|
||||
Line::from(vec![
|
||||
Span::styled(indent, Style::default().fg(Color::DarkGray)),
|
||||
Span::styled(format!("{} ", icon), Style::default().fg(icon_color)),
|
||||
Span::raw(text),
|
||||
])
|
||||
}
|
||||
266
src/builtin/terminal/mod.rs
Normal file
266
src/builtin/terminal/mod.rs
Normal file
@@ -0,0 +1,266 @@
|
||||
use crate::runtime::{Command, EventResult, PanelProvider, Source};
|
||||
use crossterm::event::{Event, KeyCode};
|
||||
use ratatui::{
|
||||
buffer::Buffer,
|
||||
layout::{Constraint, Rect},
|
||||
style::{Color, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Paragraph, Widget},
|
||||
};
|
||||
|
||||
pub struct TerminalProvider {
|
||||
pub(crate) lines: Vec<String>,
|
||||
pub(crate) scroll_offset: usize,
|
||||
pub(crate) input_buffer: String,
|
||||
cursor_pos: usize,
|
||||
pub(crate) history: Vec<String>,
|
||||
pub(crate) history_index: Option<usize>,
|
||||
}
|
||||
|
||||
impl TerminalProvider {
|
||||
pub fn new() -> Self {
|
||||
let mut provider = Self {
|
||||
lines: vec![
|
||||
"Everjoy v0.1.0 — Integrated Terminal".into(),
|
||||
"Type 'help' for available commands".into(),
|
||||
"".into(),
|
||||
],
|
||||
scroll_offset: 0,
|
||||
input_buffer: String::new(),
|
||||
cursor_pos: 0,
|
||||
history: Vec::new(),
|
||||
history_index: None,
|
||||
};
|
||||
provider.add_line("$ echo 'Hello, Everjoy!'");
|
||||
provider.add_line("Hello, Everjoy!");
|
||||
provider.add_line("");
|
||||
provider.add_line("$ cargo build");
|
||||
provider.add_line(" Compiling everjoy v0.1.0");
|
||||
provider.add_line(" Finished `dev` profile [unoptimized] target(s) in 2.34s");
|
||||
provider.add_line("");
|
||||
provider
|
||||
}
|
||||
|
||||
fn add_line(&mut self, line: impl Into<String>) {
|
||||
self.lines.push(line.into());
|
||||
let visible = 20usize;
|
||||
if self.lines.len() > visible {
|
||||
self.scroll_offset = self.lines.len() - visible;
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_input(&mut self, c: char) {
|
||||
self.input_buffer.insert(self.cursor_pos, c);
|
||||
self.cursor_pos += 1;
|
||||
self.history_index = None;
|
||||
}
|
||||
|
||||
fn handle_backspace(&mut self) {
|
||||
if self.cursor_pos > 0 {
|
||||
self.cursor_pos -= 1;
|
||||
self.input_buffer.remove(self.cursor_pos);
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_delete(&mut self) {
|
||||
if self.cursor_pos < self.input_buffer.len() {
|
||||
self.input_buffer.remove(self.cursor_pos);
|
||||
}
|
||||
}
|
||||
|
||||
fn move_cursor(&mut self, delta: isize) {
|
||||
if delta < 0 {
|
||||
self.cursor_pos = self.cursor_pos.saturating_sub((-delta) as usize);
|
||||
} else {
|
||||
self.cursor_pos = (self.cursor_pos + delta as usize).min(self.input_buffer.len());
|
||||
}
|
||||
}
|
||||
|
||||
fn submit(&mut self) {
|
||||
let cmd = self.input_buffer.trim().to_string();
|
||||
if !cmd.is_empty() {
|
||||
self.history.push(cmd.clone());
|
||||
self.add_line(format!("$ {}", cmd));
|
||||
self.process_command(&cmd);
|
||||
} else {
|
||||
self.add_line("$ ");
|
||||
}
|
||||
self.input_buffer.clear();
|
||||
self.cursor_pos = 0;
|
||||
self.history_index = None;
|
||||
}
|
||||
|
||||
fn process_command(&mut self, cmd: &str) {
|
||||
match cmd {
|
||||
"help" => {
|
||||
self.add_line("Available commands:");
|
||||
self.add_line(" help Show this help message");
|
||||
self.add_line(" clear Clear terminal output");
|
||||
self.add_line(" pwd Print working directory");
|
||||
self.add_line(" ls List files");
|
||||
self.add_line(" agents List active agents");
|
||||
self.add_line(" version Show version info");
|
||||
}
|
||||
"clear" => { self.lines.clear(); }
|
||||
"pwd" => {
|
||||
if let Ok(dir) = std::env::current_dir() {
|
||||
self.add_line(dir.display().to_string());
|
||||
}
|
||||
}
|
||||
"ls" => {
|
||||
if let Ok(entries) = std::fs::read_dir(".") {
|
||||
let mut names: Vec<_> = entries
|
||||
.filter_map(|e| e.ok())
|
||||
.map(|e| e.file_name().to_string_lossy().to_string())
|
||||
.collect();
|
||||
names.sort();
|
||||
for name in names {
|
||||
let is_dir = std::path::Path::new(&name).is_dir();
|
||||
self.add_line(format!("{}{}", if is_dir { "📁 " } else { " " }, name));
|
||||
}
|
||||
}
|
||||
}
|
||||
"agents" => {
|
||||
self.add_line("Active agents:");
|
||||
self.add_line(" 👤 Secretary — idle");
|
||||
self.add_line(" 🧭 Driver — working");
|
||||
self.add_line(" 🛡️ NegFeedback — idle");
|
||||
self.add_line(" ⚡ Executor — working");
|
||||
}
|
||||
"version" => {
|
||||
self.add_line("Everjoy v0.1.0");
|
||||
self.add_line("Built with Rust + Ratatui");
|
||||
}
|
||||
_ => {
|
||||
self.add_line(format!("Command not found: {}", cmd));
|
||||
self.add_line("Type 'help' for available commands");
|
||||
}
|
||||
}
|
||||
self.add_line("");
|
||||
}
|
||||
|
||||
fn prev_history(&mut self) {
|
||||
if self.history.is_empty() { return; }
|
||||
let idx = match self.history_index {
|
||||
Some(i) if i > 0 => i - 1,
|
||||
Some(_) => 0,
|
||||
None => self.history.len() - 1,
|
||||
};
|
||||
self.history_index = Some(idx);
|
||||
self.input_buffer = self.history[idx].clone();
|
||||
self.cursor_pos = self.input_buffer.len();
|
||||
}
|
||||
|
||||
fn next_history(&mut self) {
|
||||
if let Some(idx) = self.history_index {
|
||||
if idx + 1 < self.history.len() {
|
||||
self.history_index = Some(idx + 1);
|
||||
self.input_buffer = self.history[idx + 1].clone();
|
||||
self.cursor_pos = self.input_buffer.len();
|
||||
} else {
|
||||
self.history_index = None;
|
||||
self.input_buffer.clear();
|
||||
self.cursor_pos = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PanelProvider for TerminalProvider {
|
||||
fn as_any(&self) -> &dyn std::any::Any { self }
|
||||
fn as_any_mut(&mut self) -> &mut dyn std::any::Any { self }
|
||||
|
||||
fn id(&self) -> &str { "terminal" }
|
||||
|
||||
fn render(&mut self, area: Rect, buf: &mut Buffer) {
|
||||
let vertical = ratatui::layout::Layout::default()
|
||||
.direction(ratatui::layout::Direction::Vertical)
|
||||
.constraints([Constraint::Min(0), Constraint::Length(1)])
|
||||
.split(area);
|
||||
|
||||
let output_area = vertical[0];
|
||||
let input_area = vertical[1];
|
||||
let visible_lines = output_area.height as usize;
|
||||
if self.lines.len() > visible_lines {
|
||||
self.scroll_offset = self.scroll_offset.min(self.lines.len() - visible_lines);
|
||||
}
|
||||
|
||||
let output_lines: Vec<Line> = self
|
||||
.lines
|
||||
.iter()
|
||||
.skip(self.scroll_offset)
|
||||
.take(visible_lines)
|
||||
.map(|l| {
|
||||
if l.starts_with("$") {
|
||||
Line::from(vec![
|
||||
Span::styled("$", Style::default().fg(Color::Green).add_modifier(ratatui::style::Modifier::BOLD)),
|
||||
Span::styled(&l[1..], Style::default().fg(Color::Rgb(204, 204, 204))),
|
||||
])
|
||||
} else if l.starts_with("Error") || l.starts_with("Command not found") {
|
||||
Line::styled(l.clone(), Style::default().fg(Color::Red))
|
||||
} else if l.starts_with(" Compiling") || l.starts_with(" Finished") {
|
||||
Line::styled(l.clone(), Style::default().fg(Color::Cyan))
|
||||
} else {
|
||||
Line::styled(l.clone(), Style::default().fg(Color::Rgb(200, 200, 200)))
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Paragraph::new(output_lines)
|
||||
.block(Block::default().borders(Borders::NONE))
|
||||
.render(output_area, buf);
|
||||
|
||||
let input = Paragraph::new(vec![Line::from(vec![
|
||||
Span::styled("$ ", Style::default().fg(Color::Green).add_modifier(ratatui::style::Modifier::BOLD)),
|
||||
Span::raw(&self.input_buffer),
|
||||
])])
|
||||
.style(Style::default().bg(Color::Rgb(35, 35, 35)));
|
||||
input.render(input_area, buf);
|
||||
}
|
||||
|
||||
fn handle_event(&mut self, event: Event) -> (EventResult, Vec<Command>) {
|
||||
let commands = Vec::new();
|
||||
if let Event::Key(key) = event {
|
||||
match key.code {
|
||||
KeyCode::Char(c) => { self.handle_input(c); return (EventResult::Consumed, commands); }
|
||||
KeyCode::Backspace => { self.handle_backspace(); return (EventResult::Consumed, commands); }
|
||||
KeyCode::Delete => { self.handle_delete(); return (EventResult::Consumed, commands); }
|
||||
KeyCode::Enter => { self.submit(); return (EventResult::Consumed, commands); }
|
||||
KeyCode::Left => { self.move_cursor(-1); return (EventResult::Consumed, commands); }
|
||||
KeyCode::Right => { self.move_cursor(1); return (EventResult::Consumed, commands); }
|
||||
KeyCode::Home => { self.cursor_pos = 0; return (EventResult::Consumed, commands); }
|
||||
KeyCode::End => { self.cursor_pos = self.input_buffer.len(); return (EventResult::Consumed, commands); }
|
||||
KeyCode::Up => { self.prev_history(); return (EventResult::Consumed, commands); }
|
||||
KeyCode::Down => { self.next_history(); return (EventResult::Consumed, commands); }
|
||||
KeyCode::PageUp => {
|
||||
let visible = 10;
|
||||
self.scroll_offset = self.scroll_offset.saturating_sub(visible);
|
||||
return (EventResult::Consumed, commands);
|
||||
}
|
||||
KeyCode::PageDown => {
|
||||
let visible = 10;
|
||||
let max = self.lines.len().saturating_sub(visible);
|
||||
self.scroll_offset = (self.scroll_offset + visible).min(max);
|
||||
return (EventResult::Consumed, commands);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
(EventResult::PassThrough, commands)
|
||||
}
|
||||
|
||||
fn focus_changed(&mut self, _focused: bool) {}
|
||||
fn hide(&mut self) {}
|
||||
fn show(&mut self) {}
|
||||
fn shutdown(&mut self) {}
|
||||
|
||||
fn can_open(&self, _source: &Source, _mime_hint: Option<&str>) -> i32 {
|
||||
// Terminal doesn't compete for content presentation
|
||||
0
|
||||
}
|
||||
|
||||
fn keep_alive_on_hide(&self) -> bool { true }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
62
src/builtin/terminal/tests.rs
Normal file
62
src/builtin/terminal/tests.rs
Normal file
@@ -0,0 +1,62 @@
|
||||
use crate::builtin::terminal::TerminalProvider;
|
||||
use crate::runtime::PanelProvider;
|
||||
use crossterm::event::{Event, KeyCode, KeyEvent};
|
||||
|
||||
fn char_event(c: char) -> Event {
|
||||
Event::Key(KeyEvent::from(KeyCode::Char(c)))
|
||||
}
|
||||
|
||||
fn key_event(code: KeyCode) -> Event {
|
||||
Event::Key(KeyEvent::from(code))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_has_initial_content() {
|
||||
let term = TerminalProvider::new();
|
||||
assert!(!term.lines.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_typing_accumulates() {
|
||||
let mut term = TerminalProvider::new();
|
||||
term.handle_event(char_event('h'));
|
||||
term.handle_event(char_event('i'));
|
||||
assert_eq!(term.input_buffer, "hi");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_backspace_works() {
|
||||
let mut term = TerminalProvider::new();
|
||||
term.handle_event(char_event('a'));
|
||||
term.handle_event(char_event('b'));
|
||||
term.handle_event(key_event(KeyCode::Backspace));
|
||||
assert_eq!(term.input_buffer, "a");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_submit_clears_buffer() {
|
||||
let mut term = TerminalProvider::new();
|
||||
term.handle_event(char_event('l'));
|
||||
term.handle_event(char_event('s'));
|
||||
term.handle_event(key_event(KeyCode::Enter));
|
||||
assert_eq!(term.input_buffer, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_help_command() {
|
||||
let mut term = TerminalProvider::new();
|
||||
for c in "help".chars() { term.handle_event(char_event(c)); }
|
||||
let lines_before = term.lines.len();
|
||||
term.handle_event(key_event(KeyCode::Enter));
|
||||
let lines_after = term.lines.len();
|
||||
assert!(lines_after > lines_before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_history_navigation() {
|
||||
let mut term = TerminalProvider::new();
|
||||
for c in "test".chars() { term.handle_event(char_event(c)); }
|
||||
term.handle_event(key_event(KeyCode::Enter));
|
||||
term.handle_event(key_event(KeyCode::Up));
|
||||
assert_eq!(term.input_buffer, "test");
|
||||
}
|
||||
5
src/lib.rs
Normal file
5
src/lib.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
pub mod agent;
|
||||
pub mod builtin;
|
||||
pub mod plugin;
|
||||
pub mod runtime;
|
||||
pub mod ui;
|
||||
414
src/main.rs
Normal file
414
src/main.rs
Normal file
@@ -0,0 +1,414 @@
|
||||
use std::collections::HashMap;
|
||||
use std::io::{self, stdout};
|
||||
|
||||
use anyhow::Result;
|
||||
use crossterm::{
|
||||
event::{self, Event, KeyCode, KeyModifiers},
|
||||
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
||||
ExecutableCommand,
|
||||
};
|
||||
use ratatui::{
|
||||
backend::CrosstermBackend,
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
style::Color,
|
||||
widgets::Widget,
|
||||
Terminal,
|
||||
};
|
||||
|
||||
use everjoy::builtin;
|
||||
use everjoy::runtime::{
|
||||
Capability, Command, EventResult, ModuleRegistry, PanelType, ProviderDesc, Registrar,
|
||||
};
|
||||
use everjoy::ui;
|
||||
|
||||
enum AppState {
|
||||
Splash,
|
||||
Main,
|
||||
}
|
||||
|
||||
struct App {
|
||||
state: AppState,
|
||||
layout: ui::LayoutManager,
|
||||
focus: ui::focus::FocusManager,
|
||||
menubar: ui::menubar::Menubar,
|
||||
statusbar: ui::statusbar::StatusBar,
|
||||
splash: ui::splash::SplashScreen,
|
||||
registry: ModuleRegistry,
|
||||
/// Current active provider id per panel.
|
||||
active_providers: HashMap<PanelType, String>,
|
||||
}
|
||||
|
||||
impl App {
|
||||
fn new() -> Self {
|
||||
let mut registry = ModuleRegistry::default();
|
||||
|
||||
// Register built-in providers
|
||||
registry.register_provider(
|
||||
ProviderDesc {
|
||||
id: "filetree".into(),
|
||||
name: "File Tree".into(),
|
||||
panel: PanelType::Sidebar,
|
||||
capabilities: vec![Capability::Render, Capability::HandleEvent],
|
||||
},
|
||||
Box::new(|| Box::new(builtin::filetree::FileTreeProvider::new())),
|
||||
);
|
||||
registry.register_provider(
|
||||
ProviderDesc {
|
||||
id: "editor".into(),
|
||||
name: "Editor".into(),
|
||||
panel: PanelType::Main,
|
||||
capabilities: vec![Capability::Render, Capability::HandleEvent],
|
||||
},
|
||||
Box::new(|| Box::new(builtin::editor::EditorProvider::new())),
|
||||
);
|
||||
registry.register_provider(
|
||||
ProviderDesc {
|
||||
id: "terminal".into(),
|
||||
name: "Terminal".into(),
|
||||
panel: PanelType::Main,
|
||||
capabilities: vec![Capability::Render, Capability::HandleEvent],
|
||||
},
|
||||
Box::new(|| Box::new(builtin::terminal::TerminalProvider::new())),
|
||||
);
|
||||
registry.register_provider(
|
||||
ProviderDesc {
|
||||
id: "agentchat".into(),
|
||||
name: "Agent Chat".into(),
|
||||
panel: PanelType::Agent,
|
||||
capabilities: vec![Capability::Render, Capability::HandleEvent],
|
||||
},
|
||||
Box::new(|| Box::new(builtin::agentchat::AgentChatProvider::new())),
|
||||
);
|
||||
|
||||
// Default active providers
|
||||
let mut active_providers: HashMap<PanelType, String> = HashMap::new();
|
||||
active_providers.insert(PanelType::Sidebar, "filetree".into());
|
||||
active_providers.insert(PanelType::Main, "editor".into());
|
||||
active_providers.insert(PanelType::Agent, "agentchat".into());
|
||||
|
||||
// Ensure all default providers are instantiated
|
||||
for id in active_providers.values() {
|
||||
let _ = registry.ensure_instance(id);
|
||||
}
|
||||
|
||||
Self {
|
||||
state: AppState::Splash,
|
||||
layout: ui::LayoutManager::default(),
|
||||
focus: ui::focus::FocusManager::default(),
|
||||
menubar: ui::menubar::Menubar::default(),
|
||||
statusbar: ui::statusbar::StatusBar::default(),
|
||||
splash: ui::splash::SplashScreen,
|
||||
registry,
|
||||
active_providers,
|
||||
}
|
||||
}
|
||||
|
||||
fn run(&mut self, terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
|
||||
loop {
|
||||
self.update_statusbar();
|
||||
terminal.draw(|frame| self.render(frame.area(), frame.buffer_mut()))?;
|
||||
|
||||
if event::poll(std::time::Duration::from_millis(16))? {
|
||||
let ev = event::read()?;
|
||||
if self.handle_event(ev) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn update_statusbar(&mut self) {
|
||||
// Pull state from the active Main provider if it's an editor
|
||||
if let Some(id) = self.active_providers.get(&PanelType::Main) {
|
||||
if let Some(inst) = self.registry.ensure_instance(id) {
|
||||
if let Some(editor) = inst.as_any().downcast_ref::<builtin::editor::EditorProvider>() {
|
||||
self.statusbar.filename = editor.filename().to_string();
|
||||
self.statusbar.cursor_line = editor.cursor_line();
|
||||
self.statusbar.cursor_col = editor.cursor_col();
|
||||
if editor.is_modified() {
|
||||
self.statusbar.filename.push_str(" [+]");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Focus info
|
||||
self.statusbar.focused_panel = match self.focus.panel {
|
||||
PanelType::Sidebar => "Sidebar".into(),
|
||||
PanelType::Main => "Main".into(),
|
||||
PanelType::Agent => "Agent".into(),
|
||||
};
|
||||
}
|
||||
|
||||
fn render(&mut self, area: Rect, buf: &mut ratatui::buffer::Buffer) {
|
||||
match self.state {
|
||||
AppState::Splash => {
|
||||
self.splash.render(area, buf);
|
||||
}
|
||||
AppState::Main => {
|
||||
self.render_main(area, buf);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_main(&mut self, area: Rect, buf: &mut ratatui::buffer::Buffer) {
|
||||
let vertical = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(1),
|
||||
Constraint::Min(0),
|
||||
Constraint::Length(1),
|
||||
])
|
||||
.split(area);
|
||||
|
||||
let menubar_area = vertical[0];
|
||||
let body_area = vertical[1];
|
||||
let status_area = vertical[2];
|
||||
|
||||
self.menubar.render(menubar_area, buf);
|
||||
|
||||
let panels = self.layout.compute(body_area);
|
||||
|
||||
let sidebar_title = match self.focus.panel {
|
||||
PanelType::Sidebar => " Sidebar * ",
|
||||
_ => " Sidebar ",
|
||||
};
|
||||
let main_title = match self.focus.panel {
|
||||
PanelType::Main => " Main * ",
|
||||
_ => " Main ",
|
||||
};
|
||||
let agent_title = match self.focus.panel {
|
||||
PanelType::Agent => " Agent * ",
|
||||
_ => " Agent ",
|
||||
};
|
||||
|
||||
self.draw_panel_frame(panels.sidebar, buf, sidebar_title);
|
||||
self.draw_panel_frame(panels.main, buf, main_title);
|
||||
self.draw_panel_frame(panels.agent, buf, agent_title);
|
||||
|
||||
let sidebar_inner = inner_rect(panels.sidebar);
|
||||
let main_inner = inner_rect(panels.main);
|
||||
let agent_inner = inner_rect(panels.agent);
|
||||
|
||||
// Fill panel backgrounds
|
||||
for inner in [sidebar_inner, main_inner, agent_inner] {
|
||||
for y in inner.top()..inner.bottom() {
|
||||
for x in inner.left()..inner.right() {
|
||||
buf[(x, y)].set_bg(Color::Rgb(25, 25, 25));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.render_provider(PanelType::Sidebar, sidebar_inner, buf);
|
||||
self.render_provider(PanelType::Main, main_inner, buf);
|
||||
self.render_provider(PanelType::Agent, agent_inner, buf);
|
||||
|
||||
self.statusbar.render(status_area, buf);
|
||||
}
|
||||
|
||||
fn render_provider(&mut self, panel: PanelType, area: Rect, buf: &mut ratatui::buffer::Buffer) {
|
||||
if let Some(id) = self.active_providers.get(&panel) {
|
||||
if let Some(inst) = self.registry.ensure_instance(id) {
|
||||
inst.render(area, buf);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_panel_frame(&self, area: Rect, buf: &mut ratatui::buffer::Buffer, title: &str) {
|
||||
use ratatui::style::{Color, Style};
|
||||
use ratatui::widgets::{Block, Borders};
|
||||
|
||||
let block = Block::default()
|
||||
.title(title)
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(Color::DarkGray));
|
||||
block.render(area, buf);
|
||||
}
|
||||
|
||||
fn handle_event(&mut self, ev: Event) -> bool {
|
||||
if let Event::Key(key) = &ev {
|
||||
if key.code == KeyCode::Char('q') && key.modifiers.contains(KeyModifiers::CONTROL) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
match self.state {
|
||||
AppState::Splash => {
|
||||
if let Event::Key(_) = ev {
|
||||
self.state = AppState::Main;
|
||||
}
|
||||
false
|
||||
}
|
||||
AppState::Main => self.handle_main_event(ev),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_main_event(&mut self, ev: Event) -> bool {
|
||||
// Menubar
|
||||
if self.menubar.is_active() {
|
||||
if self.menubar.handle_event(&ev) {
|
||||
return false;
|
||||
}
|
||||
} else if let Event::Key(key) = &ev {
|
||||
if key.modifiers.contains(KeyModifiers::ALT) {
|
||||
if self.menubar.handle_event(&ev) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Focus shortcuts
|
||||
if let Event::Key(key) = &ev {
|
||||
if key.modifiers.contains(KeyModifiers::CONTROL) {
|
||||
match key.code {
|
||||
KeyCode::Char('j') => {
|
||||
self.set_focus(PanelType::Sidebar);
|
||||
return false;
|
||||
}
|
||||
KeyCode::Char('k') => {
|
||||
self.set_focus(PanelType::Main);
|
||||
return false;
|
||||
}
|
||||
KeyCode::Char('l') => {
|
||||
self.set_focus(PanelType::Agent);
|
||||
return false;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Route to focused provider
|
||||
let focused = self.focus.panel;
|
||||
let commands = if let Some(id) = self.active_providers.get(&focused) {
|
||||
if let Some(inst) = self.registry.ensure_instance(id) {
|
||||
let (result, cmds) = inst.handle_event(ev.clone());
|
||||
if matches!(result, EventResult::PassThrough) {
|
||||
if let Event::Key(key) = &ev {
|
||||
if key.modifiers.contains(KeyModifiers::ALT) {
|
||||
let _ = self.menubar.handle_event(&ev);
|
||||
}
|
||||
}
|
||||
}
|
||||
cmds
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
for cmd in commands {
|
||||
self.dispatch_command(cmd);
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
fn set_focus(&mut self, panel: PanelType) {
|
||||
let old = self.focus.panel;
|
||||
if old == panel {
|
||||
return;
|
||||
}
|
||||
self.focus.focus_panel(panel);
|
||||
|
||||
// Notify old provider
|
||||
if let Some(id) = self.active_providers.get(&old) {
|
||||
if let Some(inst) = self.registry.ensure_instance(id) {
|
||||
inst.focus_changed(false);
|
||||
}
|
||||
}
|
||||
// Notify new provider
|
||||
if let Some(id) = self.active_providers.get(&panel) {
|
||||
if let Some(inst) = self.registry.ensure_instance(id) {
|
||||
inst.focus_changed(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_command(&mut self, cmd: Command) {
|
||||
match cmd {
|
||||
Command::Present {
|
||||
panel,
|
||||
source,
|
||||
mime_hint,
|
||||
} => {
|
||||
let guessed = source.mime_hint();
|
||||
let mime_str = mime_hint.as_deref().or(guessed.as_deref());
|
||||
if let Some((id, _score)) =
|
||||
self.registry.find_best_provider(panel, &source, mime_str)
|
||||
{
|
||||
// Hide current provider on this panel if different
|
||||
if let Some(old_id) = self.active_providers.get(&panel) {
|
||||
if old_id != &id {
|
||||
if let Some(inst) = self.registry.ensure_instance(old_id) {
|
||||
inst.hide();
|
||||
}
|
||||
}
|
||||
}
|
||||
self.active_providers.insert(panel, id.clone());
|
||||
if let Some(inst) = self.registry.ensure_instance(&id) {
|
||||
inst.show();
|
||||
if self.focus.panel == panel {
|
||||
inst.focus_changed(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Command::Launch { panel, tool_id, .. } => {
|
||||
// Direct dispatch by provider id
|
||||
if let Some(old_id) = self.active_providers.get(&panel) {
|
||||
if old_id != &tool_id {
|
||||
if let Some(inst) = self.registry.ensure_instance(old_id) {
|
||||
inst.hide();
|
||||
}
|
||||
}
|
||||
}
|
||||
self.active_providers.insert(panel, tool_id.clone());
|
||||
if let Some(inst) = self.registry.ensure_instance(&tool_id) {
|
||||
inst.show();
|
||||
if self.focus.panel == panel {
|
||||
inst.focus_changed(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
Command::FocusPanel(panel) => {
|
||||
self.set_focus(panel);
|
||||
}
|
||||
Command::ShowMessage { msg, level } => {
|
||||
let color = match level {
|
||||
everjoy::runtime::MessageLevel::Info => Color::Cyan,
|
||||
everjoy::runtime::MessageLevel::Warn => Color::Yellow,
|
||||
everjoy::runtime::MessageLevel::Error => Color::Red,
|
||||
};
|
||||
self.statusbar.message = Some((msg, color));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn inner_rect(area: Rect) -> Rect {
|
||||
Rect {
|
||||
x: area.x.saturating_add(1),
|
||||
y: area.y.saturating_add(1),
|
||||
width: area.width.saturating_sub(2),
|
||||
height: area.height.saturating_sub(2),
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
enable_raw_mode()?;
|
||||
stdout().execute(EnterAlternateScreen)?;
|
||||
let mut terminal = Terminal::new(CrosstermBackend::new(stdout()))?;
|
||||
|
||||
let mut app = App::new();
|
||||
let result = app.run(&mut terminal);
|
||||
|
||||
// Shutdown all providers before exit
|
||||
app.registry.shutdown_all();
|
||||
|
||||
disable_raw_mode()?;
|
||||
stdout().execute(LeaveAlternateScreen)?;
|
||||
result
|
||||
}
|
||||
22
src/plugin/loader.rs
Normal file
22
src/plugin/loader.rs
Normal file
@@ -0,0 +1,22 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Loads external plugins (.wasm, .so, .dll).
|
||||
pub struct ExtensionLoader {
|
||||
plugin_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl ExtensionLoader {
|
||||
pub fn new(plugin_dir: PathBuf) -> Self {
|
||||
Self { plugin_dir }
|
||||
}
|
||||
|
||||
pub fn scan_and_load(&self) -> anyhow::Result<()> {
|
||||
// TODO: scan plugin_dir, load each file
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn load(&self, _path: &Path) -> anyhow::Result<()> {
|
||||
// TODO: detect format, dispatch to wasm/dylib/static loader
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
4
src/plugin/mod.rs
Normal file
4
src/plugin/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub mod loader;
|
||||
pub mod panic;
|
||||
pub mod surface;
|
||||
pub mod translator;
|
||||
14
src/plugin/panic.rs
Normal file
14
src/plugin/panic.rs
Normal file
@@ -0,0 +1,14 @@
|
||||
/// Isolates panics from external plugins so they don't crash the runtime.
|
||||
pub fn catch_plugin_panic<F, R>(plugin_id: &str, f: F) -> Option<R>
|
||||
where
|
||||
F: FnOnce() -> R + std::panic::UnwindSafe,
|
||||
{
|
||||
match std::panic::catch_unwind(f) {
|
||||
Ok(result) => Some(result),
|
||||
Err(_) => {
|
||||
eprintln!("Plugin {} panicked! Unloading.", plugin_id);
|
||||
// TODO: unload plugin, render fallback
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
35
src/plugin/surface.rs
Normal file
35
src/plugin/surface.rs
Normal file
@@ -0,0 +1,35 @@
|
||||
/// Cell grid for external plugin rendering.
|
||||
/// Runtime allocates, plugin borrows during render() call.
|
||||
#[repr(C)]
|
||||
pub struct EjSurface {
|
||||
pub width: u16,
|
||||
pub height: u16,
|
||||
pub cells: *mut EjCell,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
#[repr(C)]
|
||||
pub struct EjCell {
|
||||
pub ch: u32,
|
||||
pub style: EjStyleId,
|
||||
}
|
||||
|
||||
pub type EjStyleId = u32;
|
||||
|
||||
impl EjSurface {
|
||||
pub fn new(width: u16, height: u16) -> Self {
|
||||
let count = (width * height) as usize;
|
||||
let cells = vec![EjCell { ch: 0, style: 0 }; count].into_boxed_slice();
|
||||
let ptr = Box::into_raw(cells) as *mut EjCell;
|
||||
Self { width, height, cells: ptr }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for EjSurface {
|
||||
fn drop(&mut self) {
|
||||
unsafe {
|
||||
let count = (self.width * self.height) as usize;
|
||||
let _ = Vec::from_raw_parts(self.cells, count, count);
|
||||
}
|
||||
}
|
||||
}
|
||||
13
src/plugin/translator.rs
Normal file
13
src/plugin/translator.rs
Normal file
@@ -0,0 +1,13 @@
|
||||
/// Translates C-ABI calls to Rust trait objects.
|
||||
/// Wraps external plugins into Runtime-compatible types.
|
||||
pub struct Translator;
|
||||
|
||||
impl Translator {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
// TODO: wrap EjProviderVTable into Box<dyn PanelProvider>
|
||||
// TODO: wrap EjTool into Box<dyn Tool>
|
||||
// TODO: cell grid → Buffer blitting
|
||||
}
|
||||
9
src/runtime/ctx.rs
Normal file
9
src/runtime/ctx.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
//! ProviderCtx has been removed in favor of the Command pattern.
|
||||
//! Providers return Commands from handle_event() instead of calling
|
||||
//! into the system via a context trait.
|
||||
|
||||
/// Shared services available to all providers.
|
||||
pub trait RuntimeServices {
|
||||
// TODO: fn config(&self) -> &Config;
|
||||
// TODO: fn db(&self) -> &DbHandle;
|
||||
}
|
||||
12
src/runtime/lifecycle.rs
Normal file
12
src/runtime/lifecycle.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
/// Manages provider lifecycle: init, hide, show, shutdown.
|
||||
pub struct LifecycleManager;
|
||||
|
||||
impl LifecycleManager {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
// TODO: track active provider instances per panel
|
||||
// TODO: handle keep_alive_on_hide semantics
|
||||
// TODO: atomic unload (shutdown all providers from a plugin)
|
||||
}
|
||||
9
src/runtime/mod.rs
Normal file
9
src/runtime/mod.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
pub mod ctx;
|
||||
pub mod lifecycle;
|
||||
pub mod registry;
|
||||
pub mod traits;
|
||||
|
||||
pub use ctx::RuntimeServices;
|
||||
pub use lifecycle::LifecycleManager;
|
||||
pub use registry::{ModuleRegistry, RegisteredProvider};
|
||||
pub use traits::*;
|
||||
134
src/runtime/registry.rs
Normal file
134
src/runtime/registry.rs
Normal file
@@ -0,0 +1,134 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use ratatui::{buffer::Buffer, layout::Rect};
|
||||
use super::traits::*;
|
||||
|
||||
/// Central registry for all module contributions.
|
||||
pub struct ModuleRegistry {
|
||||
pub panel_providers: HashMap<String, RegisteredProvider>,
|
||||
pub tools: HashMap<String, Box<dyn Tool>>,
|
||||
pub menu_items: Vec<Box<dyn MenuItem>>,
|
||||
pub status_widgets: Vec<Box<dyn StatusWidget>>,
|
||||
}
|
||||
|
||||
pub struct RegisteredProvider {
|
||||
pub desc: ProviderDesc,
|
||||
pub factory: ProviderFactory,
|
||||
pub instance: Option<Box<dyn PanelProvider>>,
|
||||
}
|
||||
|
||||
impl Default for ModuleRegistry {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
panel_providers: HashMap::new(),
|
||||
tools: HashMap::new(),
|
||||
menu_items: Vec::new(),
|
||||
status_widgets: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ModuleRegistry {
|
||||
/// Get all providers registered for a given panel type.
|
||||
pub fn providers_for_panel(&self, panel: PanelType) -> Vec<&RegisteredProvider> {
|
||||
self.panel_providers
|
||||
.values()
|
||||
.filter(|rp| rp.desc.panel == panel)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Get a mutable registered provider by id.
|
||||
pub fn provider_mut(&mut self, id: &str) -> Option<&mut RegisteredProvider> {
|
||||
self.panel_providers.get_mut(id)
|
||||
}
|
||||
|
||||
/// Instantiate a provider by id if not already created.
|
||||
pub fn ensure_instance(&mut self, id: &str) -> Option<&mut Box<dyn PanelProvider>> {
|
||||
let rp = self.panel_providers.get_mut(id)?;
|
||||
if rp.instance.is_none() {
|
||||
let factory = std::mem::replace(&mut rp.factory, Box::new(|| panic!("factory consumed")));
|
||||
let mut instance = (factory)();
|
||||
instance.init();
|
||||
rp.instance = Some(instance);
|
||||
}
|
||||
rp.instance.as_mut()
|
||||
}
|
||||
|
||||
/// Find the best provider for a source on a given panel.
|
||||
/// Returns (provider_id, score). Score 0 means no match.
|
||||
pub fn find_best_provider(
|
||||
&self,
|
||||
panel: PanelType,
|
||||
source: &Source,
|
||||
mime_hint: Option<&str>,
|
||||
) -> Option<(String, i32)> {
|
||||
let mut best: Option<(String, i32)> = None;
|
||||
for rp in self.providers_for_panel(panel) {
|
||||
let score = if let Some(inst) = &rp.instance {
|
||||
inst.can_open(source, mime_hint)
|
||||
} else {
|
||||
// We can't call can_open without an instance.
|
||||
// Fallback: use a static heuristic based on desc/capabilities.
|
||||
// For now, assume all registered providers on this panel
|
||||
// might handle generic sources at a low score.
|
||||
// A real implementation could store a static scorer.
|
||||
0
|
||||
};
|
||||
if score > 0 {
|
||||
if best.as_ref().map(|(_, s)| score > *s).unwrap_or(true) {
|
||||
best = Some((rp.desc.id.clone(), score));
|
||||
}
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
/// Shutdown all provider instances.
|
||||
pub fn shutdown_all(&mut self) {
|
||||
for rp in self.panel_providers.values_mut() {
|
||||
if let Some(mut inst) = rp.instance.take() {
|
||||
inst.shutdown();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Registrar for ModuleRegistry {
|
||||
fn register_provider(
|
||||
&mut self,
|
||||
desc: ProviderDesc,
|
||||
factory: ProviderFactory,
|
||||
) {
|
||||
let id = desc.id.clone();
|
||||
self.panel_providers.insert(
|
||||
id,
|
||||
RegisteredProvider {
|
||||
desc,
|
||||
factory,
|
||||
instance: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn register_tool(&mut self, desc: ToolDesc, handler: Box<dyn Tool>) {
|
||||
self.tools.insert(desc.name.clone(), handler);
|
||||
}
|
||||
|
||||
fn register_menu_item(
|
||||
&mut self,
|
||||
desc: MenuItemDesc,
|
||||
handler: Box<dyn Fn() + Send>,
|
||||
) {
|
||||
// TODO: wrap handler into a MenuItem trait object
|
||||
let _ = (desc, handler);
|
||||
}
|
||||
|
||||
fn register_status_widget(
|
||||
&mut self,
|
||||
desc: StatusWidgetDesc,
|
||||
render: Box<dyn Fn(Rect, &mut Buffer) + Send>,
|
||||
) {
|
||||
// TODO: wrap render into a StatusWidget trait object
|
||||
let _ = (desc, render);
|
||||
}
|
||||
}
|
||||
333
src/runtime/traits.rs
Normal file
333
src/runtime/traits.rs
Normal file
@@ -0,0 +1,333 @@
|
||||
use ratatui::{buffer::Buffer, layout::Rect};
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A plugin registers its contributions during bootstrap.
|
||||
pub trait Plugin: Send {
|
||||
fn bootstrap(registrar: &mut dyn Registrar);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Source — what is being presented/launched
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Where the content comes from.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum Source {
|
||||
/// Local file or directory.
|
||||
File { path: PathBuf },
|
||||
|
||||
/// Network resource.
|
||||
Url { url: String },
|
||||
|
||||
/// In-memory blob.
|
||||
Memory { name: String, data: Vec<u8> },
|
||||
}
|
||||
|
||||
impl Source {
|
||||
/// Guess MIME type from path extension or content.
|
||||
pub fn mime_hint(&self) -> Option<String> {
|
||||
match self {
|
||||
Source::File { path } => {
|
||||
let ext = path.extension()?.to_str()?;
|
||||
match ext {
|
||||
"rs" => Some("text/rust".into()),
|
||||
"toml" => Some("text/toml".into()),
|
||||
"md" => Some("text/markdown".into()),
|
||||
"json" => Some("application/json".into()),
|
||||
"sh" | "bash" => Some("text/x-shellscript".into()),
|
||||
"py" => Some("text/x-python".into()),
|
||||
"js" => Some("text/javascript".into()),
|
||||
"ts" => Some("text/typescript".into()),
|
||||
"txt" => Some("text/plain".into()),
|
||||
_ => Some("text/plain".into()),
|
||||
}
|
||||
}
|
||||
Source::Url { url } => {
|
||||
if url.ends_with(".md") {
|
||||
Some("text/markdown".into())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
Source::Memory { name, .. } => {
|
||||
if name.ends_with(".json") {
|
||||
Some("application/json".into())
|
||||
} else {
|
||||
Some("text/plain".into())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Command — what a Provider wants the system to do
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Actions a Provider can request from the system.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum Command {
|
||||
/// Present content in a panel. The system asks providers on that panel
|
||||
/// who can handle this source, and picks the highest scorer.
|
||||
Present {
|
||||
panel: PanelType,
|
||||
source: Source,
|
||||
mime_hint: Option<String>,
|
||||
},
|
||||
|
||||
/// Launch a tool/session in a panel by id. Not a bid — direct dispatch.
|
||||
Launch {
|
||||
panel: PanelType,
|
||||
tool_id: String,
|
||||
params: serde_json::Value,
|
||||
},
|
||||
|
||||
/// Switch focus to a panel.
|
||||
FocusPanel(PanelType),
|
||||
|
||||
/// Show a transient message in the status bar.
|
||||
ShowMessage { msg: String, level: MessageLevel },
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PanelProvider
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Renders content into a panel (Sidebar, Main, or Agent).
|
||||
pub trait PanelProvider: Send {
|
||||
/// Downcast to Any for runtime type checking.
|
||||
fn as_any(&self) -> &dyn std::any::Any {
|
||||
panic!("as_any not implemented")
|
||||
}
|
||||
|
||||
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
|
||||
panic!("as_any_mut not implemented")
|
||||
}
|
||||
|
||||
/// Unique id of this provider instance.
|
||||
fn id(&self) -> &str;
|
||||
|
||||
/// Called once after the provider instance is created.
|
||||
fn init(&mut self) {}
|
||||
|
||||
/// Called every frame to render content into the given area.
|
||||
fn render(&mut self, area: Rect, buf: &mut Buffer);
|
||||
|
||||
/// Called when this provider's panel has focus and receives input.
|
||||
/// Returns handled-or-not plus any commands to dispatch.
|
||||
fn handle_event(&mut self, event: crossterm::event::Event) -> (EventResult, Vec<Command>);
|
||||
|
||||
/// Called when focus enters or leaves this provider's panel.
|
||||
fn focus_changed(&mut self, focused: bool);
|
||||
|
||||
/// Called when the provider is about to be hidden (but not destroyed).
|
||||
fn hide(&mut self);
|
||||
|
||||
/// Called when the provider becomes visible again.
|
||||
fn show(&mut self);
|
||||
|
||||
/// Called before the provider instance is destroyed.
|
||||
fn shutdown(&mut self);
|
||||
|
||||
/// Score how well this provider can handle the given source.
|
||||
/// Return 0 = cannot handle. Higher = better match.
|
||||
fn can_open(&self, _source: &Source, _mime_hint: Option<&str>) -> i32 {
|
||||
0
|
||||
}
|
||||
|
||||
/// Whether to keep this provider alive when hidden.
|
||||
fn keep_alive_on_hide(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Callable by Agents via LLM function calling.
|
||||
pub trait Tool: Send {
|
||||
fn name(&self) -> &str;
|
||||
fn description(&self) -> &str;
|
||||
fn schema(&self) -> ToolSchema;
|
||||
fn execute(&self, input: ToolInput) -> ToolOutput;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Menubar / StatusWidget
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Menubar entry.
|
||||
pub trait MenuItem: Send {
|
||||
fn id(&self) -> &str;
|
||||
fn label(&self) -> &str;
|
||||
fn parent(&self) -> Option<&str>;
|
||||
fn shortcut(&self) -> Option<&str>;
|
||||
fn invoke(&self);
|
||||
}
|
||||
|
||||
/// Status bar widget.
|
||||
pub trait StatusWidget: Send {
|
||||
fn id(&self) -> &str;
|
||||
fn position(&self) -> StatusPosition;
|
||||
fn render(&self, area: Rect, buf: &mut Buffer);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Registrar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Registration interface exposed during Plugin::bootstrap.
|
||||
pub trait Registrar {
|
||||
fn register_provider(
|
||||
&mut self,
|
||||
desc: ProviderDesc,
|
||||
factory: ProviderFactory,
|
||||
);
|
||||
|
||||
fn register_tool(&mut self, desc: ToolDesc, handler: Box<dyn Tool>);
|
||||
|
||||
fn register_menu_item(
|
||||
&mut self,
|
||||
desc: MenuItemDesc,
|
||||
handler: Box<dyn Fn() + Send>,
|
||||
);
|
||||
|
||||
fn register_status_widget(
|
||||
&mut self,
|
||||
desc: StatusWidgetDesc,
|
||||
render: Box<dyn Fn(Rect, &mut Buffer) + Send>,
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Supporting types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum EventResult {
|
||||
/// Event was handled, stop propagation.
|
||||
Consumed,
|
||||
/// Event was not handled, pass to next handler.
|
||||
PassThrough,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum PanelType {
|
||||
Sidebar,
|
||||
Main,
|
||||
Agent,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ProviderDesc {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub panel: PanelType,
|
||||
pub capabilities: Vec<Capability>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Capability {
|
||||
Render,
|
||||
HandleEvent,
|
||||
HandleDrag,
|
||||
AsyncRender,
|
||||
}
|
||||
|
||||
pub type ProviderFactory =
|
||||
Box<dyn FnOnce() -> Box<dyn PanelProvider> + Send>;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ToolDesc {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub parameters: Vec<ToolParameter>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ToolParameter {
|
||||
pub name: String,
|
||||
pub param_type: ParamType,
|
||||
pub required: bool,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum ParamType {
|
||||
String,
|
||||
Number,
|
||||
Boolean,
|
||||
Enum(Vec<String>),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ToolInput {
|
||||
pub args: HashMap<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ToolOutput {
|
||||
pub success: bool,
|
||||
pub content: String,
|
||||
pub metadata: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
impl ToolOutput {
|
||||
pub fn ok(content: impl Into<String>) -> Self {
|
||||
Self {
|
||||
success: true,
|
||||
content: content.into(),
|
||||
metadata: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn err(content: impl Into<String>) -> Self {
|
||||
Self {
|
||||
success: false,
|
||||
content: content.into(),
|
||||
metadata: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ToolSchema {
|
||||
// TODO: JSON Schema for LLM function calling
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct MenuItemDesc {
|
||||
pub id: String,
|
||||
pub label: String,
|
||||
pub parent: Option<String>,
|
||||
pub shortcut: Option<String>,
|
||||
pub order: i32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct StatusWidgetDesc {
|
||||
pub id: String,
|
||||
pub position: StatusPosition,
|
||||
pub min_width: u16,
|
||||
pub order: i32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum StatusPosition {
|
||||
Left,
|
||||
Right,
|
||||
Center,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum MessageLevel {
|
||||
Info,
|
||||
Warn,
|
||||
Error,
|
||||
}
|
||||
33
src/ui/focus.rs
Normal file
33
src/ui/focus.rs
Normal file
@@ -0,0 +1,33 @@
|
||||
use crate::runtime::PanelType;
|
||||
|
||||
/// Manages focus state across panels and widgets.
|
||||
pub struct FocusManager {
|
||||
pub panel: PanelType,
|
||||
pub menubar_active: bool,
|
||||
}
|
||||
|
||||
impl Default for FocusManager {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
panel: PanelType::Main,
|
||||
menubar_active: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FocusManager {
|
||||
pub fn focus_panel(&mut self, panel: PanelType) {
|
||||
self.panel = panel;
|
||||
self.menubar_active = false;
|
||||
}
|
||||
|
||||
pub fn focus_menubar(&mut self) {
|
||||
self.menubar_active = true;
|
||||
}
|
||||
|
||||
pub fn is_menubar_focused(&self) -> bool {
|
||||
self.menubar_active
|
||||
}
|
||||
|
||||
// TODO: handle Tab cycling within panel widgets
|
||||
}
|
||||
46
src/ui/layout.rs
Normal file
46
src/ui/layout.rs
Normal file
@@ -0,0 +1,46 @@
|
||||
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
||||
|
||||
/// Manages panel proportions and handles drag-to-resize.
|
||||
pub struct LayoutManager {
|
||||
sidebar_ratio: f32, // 0.0 ~ 1.0
|
||||
agent_ratio: f32, // 0.0 ~ 1.0
|
||||
}
|
||||
|
||||
impl Default for LayoutManager {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
sidebar_ratio: 0.20,
|
||||
agent_ratio: 0.28,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl LayoutManager {
|
||||
pub fn compute(&self, area: Rect) -> Panels {
|
||||
let main_ratio = 1.0 - self.sidebar_ratio - self.agent_ratio;
|
||||
|
||||
let horizontal = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([
|
||||
Constraint::Percentage((self.sidebar_ratio * 100.0) as u16),
|
||||
Constraint::Percentage((main_ratio * 100.0) as u16),
|
||||
Constraint::Percentage((self.agent_ratio * 100.0) as u16),
|
||||
])
|
||||
.split(area);
|
||||
|
||||
Panels {
|
||||
sidebar: horizontal[0],
|
||||
main: horizontal[1],
|
||||
agent: horizontal[2],
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: persist ratios to config
|
||||
// TODO: handle mouse drag on splitters
|
||||
}
|
||||
|
||||
pub struct Panels {
|
||||
pub sidebar: Rect,
|
||||
pub main: Rect,
|
||||
pub agent: Rect,
|
||||
}
|
||||
186
src/ui/menubar.rs
Normal file
186
src/ui/menubar.rs
Normal file
@@ -0,0 +1,186 @@
|
||||
use crossterm::event::{Event, KeyCode, KeyModifiers};
|
||||
use ratatui::{
|
||||
buffer::Buffer,
|
||||
layout::Rect,
|
||||
style::{Color, Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Paragraph, Widget},
|
||||
};
|
||||
|
||||
/// Renders the top menubar with Alt+ shortcuts and tracks active state.
|
||||
pub struct Menubar {
|
||||
items: Vec<MenuEntry>,
|
||||
active_index: Option<usize>,
|
||||
active_submenu: Option<usize>,
|
||||
}
|
||||
|
||||
struct MenuEntry {
|
||||
label: String,
|
||||
shortcut: char,
|
||||
submenu: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for Menubar {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
items: vec![
|
||||
MenuEntry {
|
||||
label: "File".into(),
|
||||
shortcut: 'F',
|
||||
submenu: vec!["New File".into(), "Open...".into(), "Save".into(), "Save All".into(), "Exit".into()],
|
||||
},
|
||||
MenuEntry {
|
||||
label: "Edit".into(),
|
||||
shortcut: 'E',
|
||||
submenu: vec!["Undo".into(), "Redo".into(), "Cut".into(), "Copy".into(), "Paste".into()],
|
||||
},
|
||||
MenuEntry {
|
||||
label: "View".into(),
|
||||
shortcut: 'V',
|
||||
submenu: vec!["Sidebar".into(), "Terminal".into(), "Zoom In".into(), "Zoom Out".into()],
|
||||
},
|
||||
MenuEntry {
|
||||
label: "Agent".into(),
|
||||
shortcut: 'A',
|
||||
submenu: vec!["Run".into(), "Stop".into(), "Settings...".into()],
|
||||
},
|
||||
MenuEntry {
|
||||
label: "Help".into(),
|
||||
shortcut: 'H',
|
||||
submenu: vec!["Documentation".into(), "Shortcuts".into(), "About".into()],
|
||||
},
|
||||
],
|
||||
active_index: None,
|
||||
active_submenu: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Menubar {
|
||||
/// Handle menubar-specific key events. Returns true if consumed.
|
||||
pub fn handle_event(&mut self, event: &Event) -> bool {
|
||||
if let Event::Key(key) = event {
|
||||
// Alt+letter to open menu
|
||||
if key.modifiers.contains(KeyModifiers::ALT) {
|
||||
if let KeyCode::Char(c) = key.code {
|
||||
let upper = c.to_ascii_uppercase();
|
||||
if let Some(idx) = self.items.iter().position(|e| e.shortcut == upper) {
|
||||
self.active_index = Some(idx);
|
||||
self.active_submenu = Some(0);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Menu navigation when active
|
||||
if self.active_index.is_some() {
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
self.active_index = None;
|
||||
self.active_submenu = None;
|
||||
return true;
|
||||
}
|
||||
KeyCode::Left => {
|
||||
if let Some(idx) = self.active_index {
|
||||
self.active_index = Some(idx.saturating_sub(1));
|
||||
self.active_submenu = Some(0);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
KeyCode::Right => {
|
||||
if let Some(idx) = self.active_index {
|
||||
self.active_index = Some((idx + 1).min(self.items.len() - 1));
|
||||
self.active_submenu = Some(0);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
KeyCode::Up => {
|
||||
if let Some(sub) = self.active_submenu {
|
||||
self.active_submenu = Some(sub.saturating_sub(1));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
KeyCode::Down => {
|
||||
if let Some(idx) = self.active_index {
|
||||
let max = self.items[idx].submenu.len().saturating_sub(1);
|
||||
if let Some(sub) = self.active_submenu {
|
||||
self.active_submenu = Some((sub + 1).min(max));
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
// TODO: invoke menu action
|
||||
self.active_index = None;
|
||||
self.active_submenu = None;
|
||||
return true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub fn is_active(&self) -> bool {
|
||||
self.active_index.is_some()
|
||||
}
|
||||
|
||||
pub fn deactivate(&mut self) {
|
||||
self.active_index = None;
|
||||
self.active_submenu = None;
|
||||
}
|
||||
}
|
||||
|
||||
impl Widget for &Menubar {
|
||||
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||
let spans: Vec<Span> = self
|
||||
.items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.flat_map(|(i, entry)| {
|
||||
let is_active = self.active_index == Some(i);
|
||||
let mut chars = entry.label.chars();
|
||||
let first = chars.next().unwrap();
|
||||
|
||||
let first_style = if is_active {
|
||||
Style::default()
|
||||
.fg(Color::Black)
|
||||
.bg(Color::White)
|
||||
.add_modifier(Modifier::UNDERLINED)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default()
|
||||
.fg(Color::Rgb(204, 204, 204))
|
||||
.add_modifier(Modifier::UNDERLINED)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
};
|
||||
let rest_style = if is_active {
|
||||
Style::default()
|
||||
.fg(Color::Black)
|
||||
.bg(Color::White)
|
||||
} else {
|
||||
Style::default().fg(Color::Rgb(204, 204, 204))
|
||||
};
|
||||
|
||||
let parts = vec![
|
||||
Span::raw(" "),
|
||||
Span::styled(first.to_string(), first_style),
|
||||
Span::styled(chars.as_str().to_string(), rest_style),
|
||||
];
|
||||
|
||||
// Render submenu if active
|
||||
if is_active {
|
||||
// We can't render a dropdown in the same line as the menubar
|
||||
// For now just highlight the active item
|
||||
}
|
||||
|
||||
parts
|
||||
})
|
||||
.collect();
|
||||
|
||||
Paragraph::new(Line::from(spans))
|
||||
.style(Style::default().bg(Color::Rgb(45, 45, 45)))
|
||||
.render(area, buf);
|
||||
}
|
||||
}
|
||||
9
src/ui/mod.rs
Normal file
9
src/ui/mod.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
pub mod focus;
|
||||
pub mod layout;
|
||||
pub mod menubar;
|
||||
pub mod router;
|
||||
pub mod scheduler;
|
||||
pub mod splash;
|
||||
pub mod statusbar;
|
||||
|
||||
pub use layout::LayoutManager;
|
||||
20
src/ui/router.rs
Normal file
20
src/ui/router.rs
Normal file
@@ -0,0 +1,20 @@
|
||||
use crossterm::event::Event;
|
||||
|
||||
/// Routes input events through the 3-layer system:
|
||||
/// 1. System emergency shortcuts
|
||||
/// 2. Focused PanelProvider
|
||||
/// 3. Global shortcuts / Menubar
|
||||
pub struct EventRouter;
|
||||
|
||||
impl EventRouter {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
pub fn route(&mut self, event: Event) {
|
||||
// TODO: Layer 1 — system shortcuts (Ctrl+C, Ctrl+Q)
|
||||
// TODO: Layer 2 — focused provider
|
||||
// TODO: Layer 3 — global shortcuts / menubar
|
||||
let _ = event;
|
||||
}
|
||||
}
|
||||
30
src/ui/scheduler.rs
Normal file
30
src/ui/scheduler.rs
Normal file
@@ -0,0 +1,30 @@
|
||||
use ratatui::{backend::Backend, Frame, Terminal};
|
||||
|
||||
/// Schedules and executes the per-frame render.
|
||||
pub struct RenderScheduler;
|
||||
|
||||
impl RenderScheduler {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
pub fn render<B: Backend>(&mut self, terminal: &mut Terminal<B>) {
|
||||
terminal
|
||||
.draw(|frame| {
|
||||
self.render_frame(frame);
|
||||
})
|
||||
.expect("render failed");
|
||||
}
|
||||
|
||||
fn render_frame(&self, frame: &mut Frame) {
|
||||
let _area = frame.area();
|
||||
// TODO:
|
||||
// 1. Draw splash if active
|
||||
// 2. Otherwise draw main UI:
|
||||
// a. Menubar
|
||||
// b. Sidebar Provider
|
||||
// c. Main Panel Provider
|
||||
// d. Agent Panel Provider
|
||||
// e. Status Bar
|
||||
}
|
||||
}
|
||||
78
src/ui/splash.rs
Normal file
78
src/ui/splash.rs
Normal file
@@ -0,0 +1,78 @@
|
||||
use ratatui::{
|
||||
buffer::Buffer,
|
||||
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
||||
style::{Color, Modifier, Style},
|
||||
text::Line,
|
||||
widgets::{Paragraph, Widget},
|
||||
};
|
||||
|
||||
/// Splash screen shown on startup.
|
||||
pub struct SplashScreen;
|
||||
|
||||
impl Widget for &SplashScreen {
|
||||
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||
// Background
|
||||
for y in area.top()..area.bottom() {
|
||||
for x in area.left()..area.right() {
|
||||
buf[(x, y)].set_bg(Color::Rgb(20, 18, 15));
|
||||
}
|
||||
}
|
||||
|
||||
let vertical = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Percentage(20),
|
||||
Constraint::Length(8),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Min(0),
|
||||
])
|
||||
.split(area);
|
||||
|
||||
// ASCII art dog
|
||||
let dog = Paragraph::new(vec![
|
||||
Line::from(" ████████ "),
|
||||
Line::from(" ████░░░░░░████ "),
|
||||
Line::from(" ████░░░░░░░░░░████ "),
|
||||
Line::from(" ████░░░▓▓▓▓▓▓░░░████ "),
|
||||
Line::from(" ████░░░▓░░░░░░▓░░░████ "),
|
||||
Line::from(" ███░░░░▓░░▓▓░░▓░░░░███ "),
|
||||
Line::from(" ███░░░░░▓░░░░░▓░░░░░███ "),
|
||||
Line::from(" ███░░░░░▓▓▓▓▓▓░░░░░███ "),
|
||||
])
|
||||
.alignment(Alignment::Center)
|
||||
.style(Style::default().fg(Color::Rgb(210, 170, 90)));
|
||||
dog.render(vertical[1], buf);
|
||||
|
||||
// Title
|
||||
let title = Paragraph::new("EVERJOY")
|
||||
.alignment(Alignment::Center)
|
||||
.style(
|
||||
Style::default()
|
||||
.fg(Color::Rgb(210, 170, 90))
|
||||
.add_modifier(Modifier::BOLD)
|
||||
.add_modifier(Modifier::UNDERLINED),
|
||||
);
|
||||
title.render(vertical[2], buf);
|
||||
|
||||
// Subtitle
|
||||
let subtitle = Paragraph::new("An AI-native TUI workflow")
|
||||
.alignment(Alignment::Center)
|
||||
.style(Style::default().fg(Color::Rgb(150, 140, 130)).add_modifier(Modifier::ITALIC));
|
||||
subtitle.render(vertical[3], buf);
|
||||
|
||||
// Version
|
||||
let version = Paragraph::new("v0.1.0 — for Hoshu Chiu & Everjoy Chiu 🐕")
|
||||
.alignment(Alignment::Center)
|
||||
.style(Style::default().fg(Color::Rgb(120, 110, 100)));
|
||||
version.render(vertical[4], buf);
|
||||
|
||||
// Hint
|
||||
let hint = Paragraph::new("◀ Press any key to start ▶")
|
||||
.alignment(Alignment::Center)
|
||||
.style(Style::default().fg(Color::Rgb(180, 180, 180)).add_modifier(Modifier::BOLD));
|
||||
hint.render(vertical[5], buf);
|
||||
}
|
||||
}
|
||||
65
src/ui/statusbar.rs
Normal file
65
src/ui/statusbar.rs
Normal file
@@ -0,0 +1,65 @@
|
||||
use ratatui::{
|
||||
buffer::Buffer,
|
||||
layout::Rect,
|
||||
style::{Color, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Paragraph, Widget},
|
||||
};
|
||||
|
||||
/// Dynamic status bar showing editor state, focus, and agent status.
|
||||
pub struct StatusBar {
|
||||
pub filename: String,
|
||||
pub file_type: String,
|
||||
pub encoding: String,
|
||||
pub cursor_line: usize,
|
||||
pub cursor_col: usize,
|
||||
pub agent_status: String,
|
||||
pub focused_panel: String,
|
||||
pub message: Option<(String, Color)>,
|
||||
}
|
||||
|
||||
impl Default for StatusBar {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
filename: "main.rs".into(),
|
||||
file_type: "Rust".into(),
|
||||
encoding: "UTF-8".into(),
|
||||
cursor_line: 1,
|
||||
cursor_col: 1,
|
||||
agent_status: "idle".into(),
|
||||
focused_panel: "Main".into(),
|
||||
message: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Widget for &StatusBar {
|
||||
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||
let left = format!(
|
||||
" {} | {} | {} | Ln {}, Col {} ",
|
||||
self.filename, self.file_type, self.encoding, self.cursor_line, self.cursor_col
|
||||
);
|
||||
let right = format!(" [{}] | {} ", self.agent_status, self.focused_panel);
|
||||
|
||||
let left_width = left.len() as u16;
|
||||
let right_width = right.len() as u16;
|
||||
let mid_width = area.width.saturating_sub(left_width + right_width);
|
||||
|
||||
let mid = if let Some((msg, color)) = &self.message {
|
||||
let padded = format!(" {:^width$} ", msg, width = mid_width as usize);
|
||||
Span::styled(padded, Style::default().fg(*color).add_modifier(ratatui::style::Modifier::BOLD))
|
||||
} else {
|
||||
Span::raw(" ".repeat(mid_width as usize))
|
||||
};
|
||||
|
||||
let line = Line::from(vec![
|
||||
Span::styled(left, Style::default().fg(Color::Rgb(204, 204, 204))),
|
||||
mid,
|
||||
Span::styled(right, Style::default().fg(Color::Rgb(204, 204, 204))),
|
||||
]);
|
||||
|
||||
Paragraph::new(line)
|
||||
.style(Style::default().bg(Color::Rgb(30, 60, 90)))
|
||||
.render(area, buf);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user