From 20553a05aaec5f4df7728290f49d4d78ead90538 Mon Sep 17 00:00:00 2001 From: Liang Jiaqing Date: Thu, 7 May 2026 18:15:08 +0800 Subject: [PATCH] tuiapp: improve session nav (ctrl+arrows), CJK-aware sidebar, README restructure --- README.md | 104 +++++++++++++++++++++++++++----------------- frontends/tuiapp.py | 73 +++++++++++++++++++++++-------- pyproject.toml | 1 + 3 files changed, 119 insertions(+), 59 deletions(-) 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 代理启动。 + + +Codeg Demo +
+ +--- + +## 💬 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 代理启动。 - - -Codeg Demo -
- ### 通用聊天命令 默认通过 `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",