diff --git a/README.md b/README.md
index 4b02d2a1..cecb40e3 100644
--- a/README.md
+++ b/README.md
@@ -81,7 +81,7 @@ git clone https://github.com/lsdefine/GenericAgent.git
cd GenericAgent
# 2. Install minimal dependencies
-pip install requests streamlit pywebview
+pip install requests streamlit pywebview textual
# 3. Configure API Key
cp mykey_template.py mykey.py
@@ -110,30 +110,26 @@ Full guide: [GETTING_STARTED.md](GETTING_STARTED.md)
---
-## 🤖 Bot Interface (Optional)
+## 🖥️ Desktop Frontends
-### Telegram Bot
+### Terminal UI (Recommended)
-```python
-# mykey.py
-tg_bot_token = 'YOUR_BOT_TOKEN'
-tg_allowed_users = [YOUR_USER_ID]
-```
+A lightweight, keyboard-driven interface built on [Textual](https://github.com/Textualize/textual). Supports multiple concurrent sessions, real-time streaming, and runs anywhere a terminal does — no browser needed.
```bash
-python frontends/tgapp.py
+python frontends/tuiapp.py
```
-### Alternative App Frontends
+> Tip: `python frontends/tuiapp.py --demo` runs a smoke test without consuming API tokens.
-Besides the default Streamlit web UI, you can also try other frontend styles:
+### Other Desktop Frontends
```bash
python frontends/qtapp.py # Qt-based desktop app
streamlit run frontends/stapp2.py # Alternative Streamlit UI
```
-### Codeg
+### Codeg
|
@@ -150,6 +146,22 @@ Place your GenericAgent directory alongside the codeg project. Codeg will auto-d
|
+---
+
+## 💬 Bot Interface (IM)
+
+### Telegram Bot
+
+```python
+# mykey.py
+tg_bot_token = 'YOUR_BOT_TOKEN'
+tg_allowed_users = [YOUR_USER_ID]
+```
+
+```bash
+python frontends/tgapp.py
+```
+
### Common Chat Commands
The default Streamlit desktop UI started by `python launch.pyw`, plus the QQ / Telegram / Feishu / WeCom / DingTalk frontends, support these chat commands:
@@ -314,7 +326,7 @@ git clone https://github.com/lsdefine/GenericAgent.git
cd GenericAgent
# 2. 安装最小依赖
-pip install requests streamlit pywebview
+pip install requests streamlit pywebview textual
# 3. 配置 API Key
cp mykey_template.py mykey.py
@@ -346,7 +358,45 @@ python launch.pyw
---
-## 🤖 Bot 接口(可选)
+## 🖥️ 桌面前端
+
+### 终端 UI(推荐)
+
+基于 [Textual](https://github.com/Textualize/textual) 的轻量键盘驱动界面。支持多会话并发、实时流式输出,有终端就能跑,无需浏览器。
+
+```bash
+python frontends/tuiapp.py
+```
+
+> 提示:`python frontends/tuiapp.py --demo` 可在不消耗 API Token 的情况下进行冒烟测试。
+
+### 其他桌面前端
+
+```bash
+python frontends/qtapp.py # 基于 Qt 的桌面应用
+streamlit run frontends/stapp2.py # 另一种 Streamlit 风格 UI
+```
+
+### Codeg前端
+
+
+|
+
+[Codeg](https://github.com/yiqi-017/codeg)(`feat/genericagent-integration` 分支)是一个桌面/Web UI,可以将 GenericAgent 与其他代理(Claude Code、Gemini、Codex 等)在统一界面中并行使用,UI 更加精美。
+
+> 此集成已可使用,部分功能仍在完善中,欢迎体验反馈。
+
+将 GenericAgent 目录放在 codeg 项目同级目录下,Codeg 会自动检测 `frontends/genericagent_acp_bridge.py` 并将 GenericAgent 作为本地 ACP 代理启动。
+
+ |
+
+
+ |
+
+
+---
+
+## 💬 Bot 接口(IM)
### 微信 Bot(个人微信)
@@ -428,32 +478,6 @@ dingtalk_client_secret = "your_app_secret"
dingtalk_allowed_users = ["your_staff_id"] # 或 ['*']
```
-### 其他 App 前端
-
-除默认的 Streamlit Web UI 外,还可以尝试不同风格的前端:
-
-```bash
-python frontends/qtapp.py # 基于 Qt 的桌面应用
-streamlit run frontends/stapp2.py # 另一种 Streamlit 风格 UI
-```
-
-### Codeg前端
-
-
-|
-
-[Codeg](https://github.com/yiqi-017/codeg)(`feat/genericagent-integration` 分支)是一个桌面/Web UI,可以将 GenericAgent 与其他代理(Claude Code、Gemini、Codex 等)在统一界面中并行使用,UI 更加精美。
-
-> 此集成已可使用,部分功能仍在完善中,欢迎体验反馈。
-
-将 GenericAgent 目录放在 codeg 项目同级目录下,Codeg 会自动检测 `frontends/genericagent_acp_bridge.py` 并将 GenericAgent 作为本地 ACP 代理启动。
-
- |
-
-
- |
-
-
### 通用聊天命令
默认通过 `python launch.pyw` 启动的 Streamlit 桌面 UI,以及 QQ / Telegram / 飞书 / 企业微信 / 钉钉前端,都支持以下命令:
diff --git a/frontends/tuiapp.py b/frontends/tuiapp.py
index 1cde05b9..379dd821 100644
--- a/frontends/tuiapp.py
+++ b/frontends/tuiapp.py
@@ -33,6 +33,7 @@ try:
from rich.panel import Panel
from rich.text import Text
from textual.app import App, ComposeResult
+ from textual.binding import Binding
from textual.containers import Horizontal, Vertical
from textual.widgets import Footer, Header, Input, RichLog, Static
except ModuleNotFoundError as exc: # pragma: no cover - exercised by manual missing-dep path
@@ -218,7 +219,7 @@ class GenericAgentTUI(App[None]):
CSS = """
Screen { layout: vertical; }
#body { height: 1fr; }
- #sidebar { width: 30; min-width: 24; border: solid $accent; padding: 0 1; }
+ #sidebar { width: 30; min-width: 24; border: solid $accent; padding: 0 1; overflow-x: hidden; }
#main { width: 1fr; }
#status { height: 3; border: solid $primary; padding: 0 1; }
#log { height: 1fr; border: solid $primary; padding: 0 1; }
@@ -231,15 +232,8 @@ class GenericAgentTUI(App[None]):
("ctrl+s", "stop_current", "Stop"),
("ctrl+f", "toggle_fold", "Fold/Unfold"),
("ctrl+q", "quit", "Quit"),
- ("ctrl+1", "switch_session(1)", "Session 1"),
- ("ctrl+2", "switch_session(2)", "Session 2"),
- ("ctrl+3", "switch_session(3)", "Session 3"),
- ("ctrl+4", "switch_session(4)", "Session 4"),
- ("ctrl+5", "switch_session(5)", "Session 5"),
- ("ctrl+6", "switch_session(6)", "Session 6"),
- ("ctrl+7", "switch_session(7)", "Session 7"),
- ("ctrl+8", "switch_session(8)", "Session 8"),
- ("ctrl+9", "switch_session(9)", "Session 9"),
+ Binding("ctrl+left", "prev_session", "←Prev", show=True, priority=True),
+ Binding("ctrl+right", "next_session", "Next→", show=True, priority=True),
]
def __init__(self, agent_factory: Optional[AgentFactory] = None, *, demo: bool = False) -> None:
@@ -290,12 +284,29 @@ class GenericAgentTUI(App[None]):
self._refresh_all()
return session
+ def action_prev_session(self) -> None:
+ """Switch to previous session."""
+ ids = sorted(self.sessions.keys())
+ if len(ids) <= 1:
+ return
+ idx = ids.index(self.current_id)
+ self.current_id = ids[(idx - 1) % len(ids)]
+ self._refresh_all()
+
+ def action_next_session(self) -> None:
+ """Switch to next session."""
+ ids = sorted(self.sessions.keys())
+ if len(ids) <= 1:
+ return
+ idx = ids.index(self.current_id)
+ self.current_id = ids[(idx + 1) % len(ids)]
+ self._refresh_all()
+
def action_switch_session(self, n: int) -> None:
- """Switch to session by id via Ctrl+N shortcut."""
+ """Switch to session by id (used by /switch command)."""
if n in self.sessions:
self.current_id = n
self._refresh_all()
- self._system(f"Switched to session #{n}.")
else:
self.notify(f"Session #{n} does not exist.", severity="warning")
@@ -494,7 +505,7 @@ class GenericAgentTUI(App[None]):
for msg in reversed(session.messages):
if msg.role == "user":
text = msg.content.strip().replace("\n", " ")
- return text[:18] + "…" if len(text) > 18 else text
+ return self._truncate_display(text, 20)
return ""
def _session_last_summary(self, session: AgentSession) -> str:
@@ -504,22 +515,46 @@ class GenericAgentTUI(App[None]):
matches = re.findall(r"\s*(.*?)\s*", msg.content, re.DOTALL)
if matches:
text = matches[-1].strip().split("\n", 1)[0].replace("\n", " ")
- return text[:18] + "…" if len(text) > 18 else text
+ return self._truncate_display(text, 20)
return ""
+ @staticmethod
+ def _truncate_display(text: str, max_width: int) -> str:
+ """Truncate text by display width (CJK chars count as 2)."""
+ import unicodedata
+ width = 0
+ result = []
+ for ch in text:
+ w = 2 if unicodedata.east_asian_width(ch) in ('W', 'F') else 1
+ if width + w > max_width:
+ result.append("…")
+ break
+ result.append(ch)
+ width += w
+ return "".join(result)
+
def _refresh_sidebar(self) -> None:
sidebar = self.query_one("#sidebar", Static)
- lines = ["[b]Sessions[/b]", ""]
+ max_w = 26 # 30 - 2(border) - 2(padding)
+ lines: list[str] = ["[b]Sessions[/b]", ""]
for sid, session in self.sessions.items():
mark = "▶" if sid == self.current_id else " "
last_q = self._session_last_user_query(session)
last_s = self._session_last_summary(session)
- lines.append(f"{mark} #{sid} {session.name} [{session.status}]")
+ status_style = "green" if session.status == "running" else "dim"
+ # Header line: "▶ #1 name status" — truncate name if needed
+ prefix = f"{mark} #{sid} "
+ suffix = f" {session.status}"
+ name_max = max_w - len(prefix) - len(suffix)
+ name_disp = self._truncate_display(session.name, max(name_max, 4))
+ lines.append(f"{prefix}{name_disp} [{status_style}]{session.status}[/{status_style}]")
if last_q:
- lines.append(f" [dim]Q: {last_q}[/dim]")
+ lines.append(f" [dim]Q:{last_q}[/dim]")
if last_s:
- lines.append(f" [dim]S: {last_s}[/dim]")
- lines.append("\n[dim]Shared temp/memory/tools; use /new, /switch.[/dim]")
+ lines.append(f" [dim]S:{last_s}[/dim]")
+ lines.append("")
+ lines.append("[dim]/new, /switch, Ctrl+N[/dim]")
+ lines.append("[dim]I have memory, just say what you want[/dim]")
sidebar.update("\n".join(lines))
def _refresh_status(self) -> None:
diff --git a/pyproject.toml b/pyproject.toml
index 5ac03269..7a60b856 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -21,6 +21,7 @@ dependencies = [
ui = [
"streamlit>=1.28",
"pywebview>=4.0",
+ "textual>=0.70",
]
all-frontends = [
"python-telegram-bot>=20.0",