Maybe Runtime subsystem done

This commit is contained in:
2026-06-04 08:19:50 +08:00
parent 06100b0561
commit 6b17a884e5
43 changed files with 7687 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
/target
prototype/

3443
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

52
Cargo.toml Normal file
View 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]

1
NOTES.MD Normal file
View File

@@ -0,0 +1 @@
i18n support

361
TECH_STACK.md Normal file
View 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 APIOpenAI、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.*

View 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
View 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.*

View 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
View 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
View File

@@ -0,0 +1,4 @@
pub mod memory;
pub mod orchestrator;
pub mod roles;
pub mod tools;

12
src/agent/orchestrator.rs Normal file
View 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
View 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
View 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")
}
}

View 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;

View 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
View 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;

View 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
View 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;

View 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
View File

@@ -0,0 +1,5 @@
pub mod agentchat;
pub mod editor;
pub mod filetree;
pub mod tasktree;
pub mod terminal;

View 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
View 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;

View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,4 @@
pub mod loader;
pub mod panic;
pub mod surface;
pub mod translator;

14
src/plugin/panic.rs Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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);
}
}