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