From 234aa41c2fa12d1ddd3779865be1b6b70bf288f7 Mon Sep 17 00:00:00 2001 From: ttiee <469784630@qq.com> Date: Sun, 15 Mar 2026 16:57:06 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=93=9D=20docs(plans):=20=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=E7=BB=9F=E4=B8=80=E5=B7=A5=E4=BD=9C=E5=8F=B0=E9=9D=A2?= =?UTF-8?q?=E6=9D=BF=E8=AE=BE=E8=AE=A1=E4=B8=8E=E8=AE=A1=E5=88=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...26-03-15-unified-workspace-panel-design.md | 231 +++++++++++++++++ .../2026-03-15-unified-workspace-panel.md | 242 ++++++++++++++++++ 2 files changed, 473 insertions(+) create mode 100644 docs/plans/2026-03-15-unified-workspace-panel-design.md create mode 100644 docs/plans/2026-03-15-unified-workspace-panel.md diff --git a/docs/plans/2026-03-15-unified-workspace-panel-design.md b/docs/plans/2026-03-15-unified-workspace-panel-design.md new file mode 100644 index 0000000..29f00d9 --- /dev/null +++ b/docs/plans/2026-03-15-unified-workspace-panel-design.md @@ -0,0 +1,231 @@ +# Telegram Unified Workspace Panel Design + +## Goal + +Add a dedicated Telegram workspace panel for day-to-day use so users can inspect the current chat state and reach the most common runtime controls from one screen. + +## Problem + +The plugin already supports individual settings selectors, a workdir browser, and a history browser, but the operational surface is still fragmented: + +- `/mode`, `/model`, `/effort`, and `/permission` are separate entrypoints. +- `/pwd`, `/cd`, and `/sessions` expose adjacent state from different commands. +- The new onboarding panel is helpful for first-run guidance, but it is intentionally narrow and should not become the permanent control surface. +- Telegram mobile users pay a high cost when they need several command round-trips just to inspect state and make one or two adjustments. + +This leaves the plugin functionally complete but operationally scattered. + +## Constraints + +- Preserve all existing commands for backward compatibility. +- Keep onboarding and daily workspace control as separate concepts. +- Reuse the existing panel token/version/message-id pattern. +- Reuse existing setting, directory, and history flows rather than duplicating their selector logic. +- Keep the panel compact enough for Telegram mobile screens. +- Do not change session persistence format or migration behavior. + +## Approaches Considered + +### 1. Thin navigation wrapper + +Add `/panel` as a summary page that only links to existing panels. + +Pros: + +- Smallest code change +- Maximum reuse of existing flows + +Cons: + +- Still requires multiple message hops +- Does not meaningfully deliver the "current workspace" control surface from issue #5 + +### 2. Dedicated workspace panel + +Add a new workspace panel state machine that summarizes the current chat state and exposes high-frequency actions, while delegating detailed selection to the existing panels and browsers. + +Pros: + +- Matches the issue goal directly +- Keeps onboarding and daily control separate +- Reuses the existing panel architecture cleanly + +Cons: + +- Requires one more panel type and callback namespace + +### 3. Expand onboarding into the daily control surface + +Reuse the onboarding panel for `/panel` and `/status`, adding more controls until it becomes the general workspace UI. + +Pros: + +- Fewer top-level panel concepts + +Cons: + +- Mixes first-run guidance with daily controls +- Makes onboarding harder to keep concise +- Increases the risk of a crowded panel with conflicting responsibilities + +## Recommended Approach + +Use approach 2. + +Implement a dedicated workspace panel for `/panel` and `/status`. Keep onboarding focused on first-run guidance, and keep the existing setting, directory, and history flows intact as reusable sub-surfaces. + +## User-Facing Behavior + +### Entry points + +- `/panel`: open the workspace panel +- `/status`: open the same workspace panel +- Existing commands remain unchanged: + - `/mode` + - `/model` + - `/effort` + - `/permission` + - `/pwd` + - `/cd` + - `/sessions` +- `/help`, `/start`, and `/codex` without a prompt continue to open the onboarding panel, not the workspace panel + +### Panel content + +The panel body should show three compact sections: + +1. Current runtime settings + - mode + - model + - effort + - permission +2. Current work context + - workdir + - whether the current chat has an active or bound session + - a short session/thread summary when available +3. Recent history summary + - up to the two most recent history entries + - short label only, enough to hint whether recent context exists + +### Panel actions + +Buttons are grouped into four rows: + +- `模式` `模型` `强度` `权限` +- `目录` `历史` +- `新会话` `停止` +- `刷新` `关闭` + +Interaction rules: + +- `模式` `模型` `强度` `权限` open the existing setting panels. +- `目录` opens the existing directory browser. +- `历史` opens the existing history browser. +- `新会话` resets the current chat while keeping it active for the next prompt. +- `停止` disconnects the current chat from Codex. +- `刷新` re-renders the workspace panel with fresh state. +- `关闭` closes the panel message. + +The workspace panel does not perform direct history restore in its first version. Recent history is informative only; recovery still happens inside the history browser. + +## Architecture + +Add a new workspace panel flow parallel to the existing onboarding, setting, history, and directory flows. + +### Service layer + +In `src/nonebot_plugin_codex/service.py` add: + +- workspace callback prefix constant +- workspace stale-message constant +- `WorkspacePanelState` +- callback encode/decode helpers +- `open_workspace_panel(...)` +- `get_workspace_panel(...)` +- `remember_workspace_panel_message(...)` +- `close_workspace_panel(...)` +- `navigate_workspace_panel(...)` +- `render_workspace_panel(...)` + +The rendering path should reuse existing preference, session, and history accessors. It should not embed detailed selector logic already handled by the setting, directory, or history flows. + +### Telegram handler layer + +In `src/nonebot_plugin_codex/telegram.py` add: + +- workspace callback predicate +- send workspace panel helpers +- edit-or-resend helper for workspace panels +- `/panel` handler +- `/status` handler +- workspace callback handler + +The callback handler should delegate to existing `send_*_to_chat(...)` methods where possible and only own workspace-specific actions such as refresh, close, new, and stop. + +### Plugin registration and command metadata + +Update: + +- `src/nonebot_plugin_codex/__init__.py` +- `src/nonebot_plugin_codex/telegram_commands.py` + +Register: + +- `/panel` +- `/status` +- workspace callback matcher + +Keep command metadata centralized in `telegram_commands.py`. + +## Data Flow + +1. User sends `/panel` or `/status`. +2. Handler opens workspace panel state in the service layer. +3. Service renders summary text and inline keyboard. +4. Telegram handler sends the message and records the panel message ID. +5. User presses a button. +6. Callback handler validates token and version, then either: + - opens an existing sub-panel/browser, or + - applies a workspace-specific action and refreshes/closes the panel. + +## Error Handling + +- Invalid or stale callback payloads show a stale-panel alert, matching existing panel behavior. +- If editing the original workspace panel fails, resend it and remember the new message ID. +- Delegated sub-panels retain their existing behavior and wording. +- `停止` should remain safe even when no session is currently bound. + +## Testing Strategy + +Add tests for: + +- `/panel` and `/status` command metadata +- `/panel` and `/status` handlers opening the workspace panel +- workspace callback dispatch to: + - settings + - directory browser + - history browser + - new session + - stop + - refresh + - close +- recent history summary appearing in rendered panel text +- stale callback behavior +- no regression in existing commands and existing panel handlers + +Run: + +- `pdm run pytest -q` +- `pdm run ruff check .` + +## Risks + +- The panel can become too dense if more controls are added casually. +- Session summary text can become noisy if full thread identifiers are rendered verbosely. +- Pulling in too much history detail can duplicate the history browser instead of complementing it. + +## Mitigations + +- Keep the first version focused on summary plus high-frequency actions. +- Limit recent-history display to two compact entries. +- Keep deep navigation inside the existing dedicated panels. diff --git a/docs/plans/2026-03-15-unified-workspace-panel.md b/docs/plans/2026-03-15-unified-workspace-panel.md new file mode 100644 index 0000000..a553ae3 --- /dev/null +++ b/docs/plans/2026-03-15-unified-workspace-panel.md @@ -0,0 +1,242 @@ +# Unified Workspace Panel Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add a dedicated Telegram workspace panel for `/panel` and `/status` that centralizes the current chat state and exposes one-tap entry to the existing runtime controls. + +**Architecture:** Reuse the existing Telegram panel architecture already used by onboarding, settings, directory browsing, and history browsing. Add one new workspace panel state machine in the service layer, wire two new commands and a callback namespace in the Telegram layer, and keep detailed selection delegated to the already-existing panels. + +**Tech Stack:** Python 3.10+, NoneBot 2, nonebot-adapter-telegram, pytest, Pydantic/dataclasses + +--- + +### Task 1: Lock command metadata for `/panel` and `/status` + +**Files:** +- Modify: `tests/test_telegram_commands.py` +- Modify: `src/nonebot_plugin_codex/telegram_commands.py` + +**Step 1: Write the failing test** + +Add assertions that: + +- `panel` and `status` appear in `TELEGRAM_COMMAND_SPECS` +- `build_telegram_commands()` includes both commands with Chinese descriptions +- `build_plugin_usage()` includes `/panel` and `/status` + +**Step 2: Run test to verify it fails** + +Run: `pdm run pytest tests/test_telegram_commands.py -q` +Expected: FAIL because the command metadata does not include `panel` or `status` + +**Step 3: Write minimal implementation** + +Update `src/nonebot_plugin_codex/telegram_commands.py` to add: + +- `panel`: "打开当前工作台" +- `status`: "打开当前工作台" + +Keep metadata centralized in the existing tuple. + +**Step 4: Run test to verify it passes** + +Run: `pdm run pytest tests/test_telegram_commands.py -q` +Expected: PASS + +**Step 5: Commit** + +```bash +git add tests/test_telegram_commands.py src/nonebot_plugin_codex/telegram_commands.py +git commit -m "✅ test(telegram): 补充工作台命令元数据断言" +``` + +### Task 2: Define workspace handler expectations first + +**Files:** +- Modify: `tests/test_telegram_handlers.py` + +**Step 1: Write the failing test** + +Add tests that: + +- `handle_panel()` opens the workspace panel +- `handle_status()` opens the same workspace panel +- the sent payload includes inline keyboard markup + +Extend `FakeService` only as needed for new workspace methods and state. + +**Step 2: Run test to verify it fails** + +Run: `pdm run pytest tests/test_telegram_handlers.py -q` +Expected: FAIL because `TelegramHandlers` has no workspace-panel handlers or service calls + +**Step 3: Write minimal implementation** + +Update only the test scaffolding needed to support the new expectations after production code is added. + +**Step 4: Run test to verify it still fails for the right reason** + +Run: `pdm run pytest tests/test_telegram_handlers.py -q` +Expected: FAIL due to missing production behavior, not broken test scaffolding + +**Step 5: Commit** + +```bash +git add tests/test_telegram_handlers.py +git commit -m "✅ test(telegram): 定义工作台入口行为" +``` + +### Task 3: Add workspace panel state and rendering in the service layer + +**Files:** +- Modify: `src/nonebot_plugin_codex/service.py` +- Modify: `tests/test_service.py` + +**Step 1: Write the failing test** + +Add focused service tests that cover: + +- opening and rendering the workspace panel +- summary text for mode, model, effort, permission, workdir, and session state +- recent history summary limited to the newest one or two entries +- refresh navigation returning a new panel version + +**Step 2: Run test to verify it fails** + +Run: `pdm run pytest tests/test_service.py -q` +Expected: FAIL because workspace panel state/rendering helpers do not exist + +**Step 3: Write minimal implementation** + +In `src/nonebot_plugin_codex/service.py`, add: + +- `WORKSPACE_CALLBACK_PREFIX` +- `WORKSPACE_STALE_MESSAGE` +- `WorkspacePanelState` +- `encode_workspace_callback(...)` +- `decode_workspace_callback(...)` +- `open_workspace_panel(...)` +- `get_workspace_panel(...)` +- `remember_workspace_panel_message(...)` +- `close_workspace_panel(...)` +- `navigate_workspace_panel(...)` +- `render_workspace_panel(...)` + +Reuse existing preference, session, and history data. Keep the panel compact and delegate deep actions elsewhere. + +**Step 4: Run test to verify it passes** + +Run: `pdm run pytest tests/test_service.py -q` +Expected: PASS for the new workspace-panel coverage + +**Step 5: Commit** + +```bash +git add src/nonebot_plugin_codex/service.py tests/test_service.py +git commit -m "✨ feat(service): 添加工作台面板状态与渲染" +``` + +### Task 4: Wire workspace handlers and callback routing + +**Files:** +- Modify: `src/nonebot_plugin_codex/telegram.py` +- Modify: `src/nonebot_plugin_codex/__init__.py` +- Modify: `tests/test_telegram_handlers.py` + +**Step 1: Write the failing test** + +Add or extend tests for workspace callback actions: + +- `mode`, `model`, `effort`, `permission` open the corresponding setting panels +- `browse` opens the directory browser +- `history` opens the history browser +- `new` resets chat and returns the expected notice +- `stop` disconnects the current chat +- `refresh` re-renders the workspace panel +- `close` closes the panel message +- stale callback payloads return the workspace stale message + +**Step 2: Run test to verify it fails** + +Run: `pdm run pytest tests/test_telegram_handlers.py -q` +Expected: FAIL because workspace callbacks are not registered or handled + +**Step 3: Write minimal implementation** + +Update `src/nonebot_plugin_codex/telegram.py` and `src/nonebot_plugin_codex/__init__.py` to: + +- register `/panel` and `/status` +- add workspace callback detection and handling +- delegate to existing sub-panels and browsers +- support direct `new`, `stop`, `refresh`, and `close` actions + +Do not change existing onboarding behavior. + +**Step 4: Run test to verify it passes** + +Run: `pdm run pytest tests/test_telegram_handlers.py -q` +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/nonebot_plugin_codex/telegram.py src/nonebot_plugin_codex/__init__.py tests/test_telegram_handlers.py +git commit -m "✨ feat(telegram): 接入统一工作台面板" +``` + +### Task 5: Update README and run final verification + +**Files:** +- Modify: `README.md` +- Modify: `tests/test_telegram_commands.py` +- Modify: `tests/test_telegram_handlers.py` +- Modify: `tests/test_service.py` + +**Step 1: Tighten any missing assertions** + +Ensure command usage and handler tests still cover the documented `/panel` and `/status` behavior. + +**Step 2: Run targeted tests** + +Run: + +- `pdm run pytest tests/test_telegram_commands.py -q` +- `pdm run pytest tests/test_service.py -q` +- `pdm run pytest tests/test_telegram_handlers.py -q` + +Expected: PASS before docs update + +**Step 3: Write minimal documentation** + +Update `README.md` command and workflow sections to mention: + +- `/panel` +- `/status` +- the unified workspace panel as the day-to-day control surface + +Keep docs concise and aligned with the issue scope. + +**Step 4: Run full verification** + +Run: + +- `pdm run pytest -q` +- `pdm run ruff check .` + +Expected: PASS + +**Step 5: Commit** + +```bash +git add README.md tests/test_telegram_commands.py tests/test_service.py tests/test_telegram_handlers.py +git commit -m "📝 docs(readme): 补充统一工作台面板说明" +``` + +Plan complete and saved to `docs/plans/2026-03-15-unified-workspace-panel.md`. + +Two execution options: + +1. Subagent-Driven (this session) - I dispatch fresh subagent per task, review between tasks, fast iteration +2. Parallel Session (separate) - Open new session with executing-plans, batch execution with checkpoints + +The user already asked to continue to implementation and final PR, so execute in this session. From 945911ddfeaae2571c1669d6b887fa1969aa3842 Mon Sep 17 00:00:00 2001 From: ttiee <469784630@qq.com> Date: Sun, 15 Mar 2026 17:02:56 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=E2=9C=A8=20feat(telegram):=20=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=E7=BB=9F=E4=B8=80=E5=B7=A5=E4=BD=9C=E5=8F=B0=E9=9D=A2?= =?UTF-8?q?=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 7 + src/nonebot_plugin_codex/__init__.py | 22 ++ src/nonebot_plugin_codex/service.py | 215 ++++++++++++++++++ src/nonebot_plugin_codex/telegram.py | 144 ++++++++++++ src/nonebot_plugin_codex/telegram_commands.py | 10 + tests/test_service.py | 50 ++++ tests/test_telegram_commands.py | 9 +- tests/test_telegram_handlers.py | 186 +++++++++++++++ 8 files changed, 641 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index ad4ab02..3d78352 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,7 @@ codex_workdir = "/home/yourname" ```text /codex +/panel /cd /home/yourname/projects/demo /mode resume 然后继续直接发送普通文本消息续聊 @@ -116,6 +117,8 @@ codex_workdir = "/home/yourname" `/codex` 不带参数时会打开一个 Telegram 内的使用引导面板,方便你直接查看当前模式、工作目录、设置摘要,并进入目录浏览、设置面板或历史会话。 +`/panel` 和 `/status` 会打开统一的“当前工作台”面板,把模式、模型、推理强度、权限、工作目录、当前会话状态和最近历史摘要放在同一屏里,并提供进入设置、目录、历史、新会话和停止会话的快捷操作。 + 你也可以直接把首条任务跟在 `/codex` 后面: ```text @@ -134,6 +137,7 @@ codex_workdir = "/home/yourname" ```text /help /start +/panel ``` ## 配置说明 @@ -183,6 +187,8 @@ codex_stream_read_limit = 1048576 | `/codex [prompt]` | 打开引导面板,或直接附带首条任务连接 Codex | | `/help` | 打开使用引导面板 | | `/start` | 打开使用引导面板 | +| `/panel` | 打开统一工作台面板 | +| `/status` | 打开统一工作台面板 | | `/mode [resume\|exec]` | 查看或切换默认模式 | | `/exec ` | 以一次性 `exec` 模式执行任务 | | `/new` | 新建当前聊天会话 | @@ -216,6 +222,7 @@ codex_stream_read_limit = 1048576 ## 目录与历史会话 +- `/panel` 或 `/status` 会打开统一工作台,一屏查看当前设置、工作目录、会话状态和最近历史,并跳转到常用控制面板。 - `/cd` 可打开目录浏览器,逐级进入目录、切换 Home、显示隐藏目录,并把当前浏览目录设置为工作目录。 - `/sessions` 会列出 native 与 exec 历史会话,便于恢复此前任务。 - 历史会话恢复时会尝试切回原始工作目录;如果原目录不存在,会保留当前目录并给出提示。 diff --git a/src/nonebot_plugin_codex/__init__.py b/src/nonebot_plugin_codex/__init__.py index e12fa87..38c521e 100644 --- a/src/nonebot_plugin_codex/__init__.py +++ b/src/nonebot_plugin_codex/__init__.py @@ -88,6 +88,8 @@ async def _sync_telegram_commands(bot: Bot) -> None: codex_cmd = on_command("codex", priority=10, block=True) help_cmd = on_command("help", priority=10, block=True) start_cmd = on_command("start", priority=10, block=True) + panel_cmd = on_command("panel", priority=10, block=True) + status_cmd = on_command("status", priority=10, block=True) mode_cmd = on_command("mode", priority=10, block=True) exec_cmd = on_command("exec", priority=10, block=True) new_cmd = on_command("new", priority=10, block=True) @@ -125,6 +127,12 @@ async def _sync_telegram_commands(bot: Bot) -> None: block=True, rule=handlers.is_onboarding_callback, ) + workspace_callback = on_type( + CallbackQueryEvent, + priority=10, + block=True, + rule=handlers.is_workspace_callback, + ) @codex_cmd.handle() async def _handle_codex( @@ -140,6 +148,14 @@ async def _handle_help(bot: Bot, event: MessageEvent) -> None: async def _handle_start(bot: Bot, event: MessageEvent) -> None: await handlers.handle_start(bot, event) + @panel_cmd.handle() + async def _handle_panel(bot: Bot, event: MessageEvent) -> None: + await handlers.handle_panel(bot, event) + + @status_cmd.handle() + async def _handle_status(bot: Bot, event: MessageEvent) -> None: + await handlers.handle_status(bot, event) + @mode_cmd.handle() async def _handle_mode( bot: Bot, event: MessageEvent, args: Message = CommandArg() @@ -220,6 +236,12 @@ async def _handle_onboarding_callback( ) -> None: await handlers.handle_onboarding_callback(bot, event) + @workspace_callback.handle() + async def _handle_workspace_callback( + bot: Bot, event: CallbackQueryEvent + ) -> None: + await handlers.handle_workspace_callback(bot, event) + @follow_up.handle() async def _handle_follow_up(bot: Bot, event: MessageEvent) -> None: await handlers.handle_follow_up(bot, event) diff --git a/src/nonebot_plugin_codex/service.py b/src/nonebot_plugin_codex/service.py index 09b94d9..6b60b3d 100644 --- a/src/nonebot_plugin_codex/service.py +++ b/src/nonebot_plugin_codex/service.py @@ -40,6 +40,8 @@ SUPPORTED_SETTING_PANELS = {"mode", "model", "effort", "permission"} ONBOARDING_CALLBACK_PREFIX = "cop" ONBOARDING_STALE_MESSAGE = "引导面板已失效,请重新执行 /codex" +WORKSPACE_CALLBACK_PREFIX = "cwp" +WORKSPACE_STALE_MESSAGE = "工作台面板已失效,请重新执行 /panel" @dataclass(slots=True) @@ -205,6 +207,14 @@ class OnboardingPanelState: message_id: int | None = None +@dataclass(slots=True) +class WorkspacePanelState: + chat_key: str + token: str + version: int + message_id: int | None = None + + def build_chat_key(chat_type: str, chat_id: int) -> str: if chat_type == "private": return f"private_{chat_id}" @@ -356,6 +366,22 @@ def decode_onboarding_callback(payload: str) -> tuple[str, int, str]: return token, version, parts[3] +def encode_workspace_callback(token: str, version: int, action: str) -> str: + return f"{WORKSPACE_CALLBACK_PREFIX}:{token}:{version}:{action}" + + +def decode_workspace_callback(payload: str) -> tuple[str, int, str]: + parts = payload.split(":") + if len(parts) != 4 or parts[0] != WORKSPACE_CALLBACK_PREFIX: + raise ValueError("无效的工作台回调。") + token = parts[1] + try: + version = int(parts[2]) + except ValueError as exc: + raise ValueError("无效的工作台回调。") from exc + return token, version, parts[3] + + def parse_event_line(line: str) -> dict[str, Any] | None: try: payload = json.loads(line) @@ -528,6 +554,7 @@ def __init__( self.history_browsers: dict[str, HistoryBrowserState] = {} self.setting_panels: dict[str, SettingPanelState] = {} self.onboarding_panels: dict[str, OnboardingPanelState] = {} + self.workspace_panels: dict[str, WorkspacePanelState] = {} self._native_history_entries: list[HistoricalSessionSummary] = [] self._native_history_loaded = False self._history_log_cache: dict[str, HistoryLogCacheEntry] = {} @@ -1715,6 +1742,194 @@ def close_onboarding_panel(self, chat_key: str, token: str, version: int) -> Non self.get_onboarding_panel(chat_key, token=token, version=version) self.onboarding_panels.pop(chat_key, None) + def _replace_workspace_panel_state( + self, + chat_key: str, + *, + previous: WorkspacePanelState | None = None, + ) -> WorkspacePanelState: + state = WorkspacePanelState( + chat_key=chat_key, + token=previous.token if previous else self._make_browser_token(), + version=(previous.version + 1) if previous else 1, + message_id=previous.message_id if previous else None, + ) + self.workspace_panels[chat_key] = state + return state + + def open_workspace_panel(self, chat_key: str) -> WorkspacePanelState: + self.get_preferences(chat_key) + return self._replace_workspace_panel_state(chat_key) + + def get_workspace_panel( + self, + chat_key: str, + token: str | None = None, + version: int | None = None, + ) -> WorkspacePanelState: + state = self.workspace_panels.get(chat_key) + if state is None: + raise ValueError(WORKSPACE_STALE_MESSAGE) + if token is not None and state.token != token: + raise ValueError(WORKSPACE_STALE_MESSAGE) + if version is not None and state.version != version: + raise ValueError(WORKSPACE_STALE_MESSAGE) + return state + + def remember_workspace_panel_message( + self, + chat_key: str, + token: str, + message_id: int | None, + ) -> None: + if message_id is None: + return + panel = self.get_workspace_panel(chat_key, token=token) + panel.message_id = message_id + + def close_workspace_panel(self, chat_key: str, token: str, version: int) -> None: + self.get_workspace_panel(chat_key, token=token, version=version) + self.workspace_panels.pop(chat_key, None) + + def _workspace_active_mode(self, chat_key: str, preferences: ChatPreferences) -> str: + session = self.sessions.get(chat_key) + if session is not None and session.active_mode in {"resume", "exec"}: + return session.active_mode + return preferences.default_mode + + def _workspace_session_summary(self, chat_key: str) -> str: + session = self.sessions.get(chat_key) + if session is None: + return "未开始" + active_mode = ( + session.active_mode + if session.active_mode in {"resume", "exec"} + else "resume" + ) + thread_id = ( + self._current_exec_thread_id(session) + if active_mode == "exec" + else session.native_thread_id or session.thread_id + ) + if not thread_id and not session.active: + return "未开始" + if not thread_id: + return f"{active_mode} | 未绑定" + return f"{active_mode} | {thread_id}" + + def _workspace_recent_history_lines(self) -> list[str]: + try: + entries = self.list_history_sessions()[:2] + except ValueError: + return ["最近历史:不可用"] + if not entries: + return ["最近历史:无"] + lines = ["最近历史:"] + for entry in entries: + lines.append( + "- " + f"{entry.thread_name} | " + f"{self._format_history_relative_time(entry.updated_at)}" + ) + return lines + + def navigate_workspace_panel( + self, + chat_key: str, + token: str, + version: int, + action: str, + ) -> WorkspacePanelState: + panel = self.get_workspace_panel(chat_key, token=token, version=version) + if action != "refresh": + raise ValueError("未知工作台操作。") + return self._replace_workspace_panel_state(chat_key, previous=panel) + + def render_workspace_panel( + self, chat_key: str + ) -> tuple[str, InlineKeyboardMarkup]: + panel = self.get_workspace_panel(chat_key) + preferences = self.get_preferences(chat_key) + lines = [ + "当前工作台", + f"当前模式:{self._workspace_active_mode(chat_key, preferences)}", + f"当前设置:{format_preferences_summary(preferences)}", + f"当前工作目录:{preferences.workdir}", + f"当前会话:{self._workspace_session_summary(chat_key)}", + *self._workspace_recent_history_lines(), + ] + keyboard = [ + [ + InlineKeyboardButton( + text="模式", + callback_data=encode_workspace_callback( + panel.token, panel.version, "mode" + ), + ), + InlineKeyboardButton( + text="模型", + callback_data=encode_workspace_callback( + panel.token, panel.version, "model" + ), + ), + InlineKeyboardButton( + text="强度", + callback_data=encode_workspace_callback( + panel.token, panel.version, "effort" + ), + ), + InlineKeyboardButton( + text="权限", + callback_data=encode_workspace_callback( + panel.token, panel.version, "permission" + ), + ), + ], + [ + InlineKeyboardButton( + text="目录", + callback_data=encode_workspace_callback( + panel.token, panel.version, "browse" + ), + ), + InlineKeyboardButton( + text="历史", + callback_data=encode_workspace_callback( + panel.token, panel.version, "history" + ), + ), + ], + [ + InlineKeyboardButton( + text="新会话", + callback_data=encode_workspace_callback( + panel.token, panel.version, "new" + ), + ), + InlineKeyboardButton( + text="停止", + callback_data=encode_workspace_callback( + panel.token, panel.version, "stop" + ), + ), + ], + [ + InlineKeyboardButton( + text="刷新", + callback_data=encode_workspace_callback( + panel.token, panel.version, "refresh" + ), + ), + InlineKeyboardButton( + text="关闭", + callback_data=encode_workspace_callback( + panel.token, panel.version, "close" + ), + ), + ], + ] + return "\n".join(lines), InlineKeyboardMarkup(inline_keyboard=keyboard) + def render_onboarding_panel( self, chat_key: str ) -> tuple[str, InlineKeyboardMarkup]: diff --git a/src/nonebot_plugin_codex/telegram.py b/src/nonebot_plugin_codex/telegram.py index b3b019c..ea7474a 100644 --- a/src/nonebot_plugin_codex/telegram.py +++ b/src/nonebot_plugin_codex/telegram.py @@ -19,10 +19,13 @@ ONBOARDING_CALLBACK_PREFIX, SETTING_STALE_MESSAGE, SETTING_CALLBACK_PREFIX, + WORKSPACE_STALE_MESSAGE, + WORKSPACE_CALLBACK_PREFIX, CodexBridgeService, chunk_text, build_chat_key, decode_onboarding_callback, + decode_workspace_callback, format_result_text, decode_browser_callback, decode_history_callback, @@ -354,6 +357,11 @@ async def is_onboarding_callback(self, event: CallbackQueryEvent) -> bool: f"{ONBOARDING_CALLBACK_PREFIX}:" ) + async def is_workspace_callback(self, event: CallbackQueryEvent) -> bool: + return isinstance(event.data, str) and event.data.startswith( + f"{WORKSPACE_CALLBACK_PREFIX}:" + ) + def callback_message_id(self, event: CallbackQueryEvent) -> int | None: message = getattr(event, "message", None) return getattr(message, "message_id", None) @@ -446,6 +454,30 @@ async def send_onboarding_panel( getattr(message, "message_id", None), ) + async def send_workspace_panel( + self, bot: Bot, event: MessageEvent, chat_key: str + ) -> None: + panel = self.service.open_workspace_panel(chat_key) + text, markup = self.service.render_workspace_panel(chat_key) + message = await self.send_event_message(bot, event, text, reply_markup=markup) + self.service.remember_workspace_panel_message( + chat_key, + panel.token, + getattr(message, "message_id", None), + ) + + async def send_workspace_panel_to_chat( + self, bot: Bot, chat_id: int, chat_key: str + ) -> None: + panel = self.service.open_workspace_panel(chat_key) + text, markup = self.service.render_workspace_panel(chat_key) + message = await self.send_chat_message(bot, chat_id, text, reply_markup=markup) + self.service.remember_workspace_panel_message( + chat_key, + panel.token, + getattr(message, "message_id", None), + ) + async def edit_or_resend_browser( self, bot: Bot, @@ -547,6 +579,39 @@ async def edit_or_resend_setting_panel( getattr(message, "message_id", None), ) + async def edit_or_resend_workspace_panel( + self, + bot: Bot, + event: CallbackQueryEvent, + chat_key: str, + ) -> None: + panel = self.service.get_workspace_panel(chat_key) + text, markup = self.service.render_workspace_panel(chat_key) + message_id = self.callback_message_id(event) or panel.message_id + chat_id = self.event_chat(event).id + try: + if message_id is None: + raise ValueError("missing message id") + await self.edit_message( + bot, + chat_id=chat_id, + message_id=message_id, + text=text, + reply_markup=markup, + ) + self.service.remember_workspace_panel_message( + chat_key, panel.token, message_id + ) + except Exception: + message = await self.send_chat_message( + bot, chat_id, text, reply_markup=markup + ) + self.service.remember_workspace_panel_message( + chat_key, + panel.token, + getattr(message, "message_id", None), + ) + async def handle_codex(self, bot: Bot, event: MessageEvent, args: Message) -> None: chat_key = self.chat_key(event) session = self.service.activate_chat(chat_key) @@ -569,6 +634,12 @@ async def handle_help(self, bot: Bot, event: MessageEvent) -> None: async def handle_start(self, bot: Bot, event: MessageEvent) -> None: await self.send_onboarding_panel(bot, event, self.chat_key(event)) + async def handle_panel(self, bot: Bot, event: MessageEvent) -> None: + await self.send_workspace_panel(bot, event, self.chat_key(event)) + + async def handle_status(self, bot: Bot, event: MessageEvent) -> None: + await self.send_workspace_panel(bot, event, self.chat_key(event)) + async def handle_mode(self, bot: Bot, event: MessageEvent, args: Message) -> None: chat_key = self.chat_key(event) mode = args.extract_plain_text().strip() @@ -890,6 +961,79 @@ async def handle_onboarding_callback( event.id, text=self.error_text(exc), show_alert=True ) + async def handle_workspace_callback( + self, bot: Bot, event: CallbackQueryEvent + ) -> None: + if not isinstance(event.data, str): + await bot.answer_callback_query( + event.id, text=WORKSPACE_STALE_MESSAGE, show_alert=True + ) + return + + try: + chat_key = self.chat_key(event) + chat_id = self.event_chat(event).id + token, version, action = decode_workspace_callback(event.data) + self.service.get_workspace_panel(chat_key, token=token, version=version) + if action in {"mode", "model", "effort", "permission"}: + await self.send_setting_panel_to_chat(bot, chat_id, chat_key, action) + await bot.answer_callback_query(event.id) + return + if action == "browse": + await self.send_browser_to_chat(bot, chat_id, chat_key) + await bot.answer_callback_query(event.id) + return + if action == "history": + await self.send_history_browser_to_chat(bot, chat_id, chat_key) + await bot.answer_callback_query(event.id) + return + if action == "new": + await self.service.reset_chat(chat_key, keep_active=True) + await self.send_chat_message( + bot, + chat_id, + ( + "已清空当前 Codex 会话。下一条普通消息会按以下设置新开会话:\n" + f"{self.current_summary(chat_key)}" + ), + ) + await bot.answer_callback_query(event.id, text="已新开会话。") + return + if action == "stop": + await self.service.reset_chat(chat_key, keep_active=False) + await self.send_chat_message( + bot, chat_id, "已断开当前聊天窗口的 Codex 会话。" + ) + await bot.answer_callback_query(event.id, text="已停止。") + return + if action == "close": + self.service.close_workspace_panel(chat_key, token, version) + message_id = self.callback_message_id(event) + if message_id is not None: + await self.edit_message( + bot, + chat_id=chat_id, + message_id=message_id, + text="工作台已关闭。", + reply_markup=None, + ) + await bot.answer_callback_query(event.id, text="已关闭。") + return + self.service.navigate_workspace_panel(chat_key, token, version, action) + await self.edit_or_resend_workspace_panel(bot, event, chat_key) + await bot.answer_callback_query(event.id) + except ValueError as exc: + text = str(exc) or WORKSPACE_STALE_MESSAGE + await bot.answer_callback_query( + event.id, + text=text, + show_alert=text == WORKSPACE_STALE_MESSAGE, + ) + except RuntimeError as exc: + await bot.answer_callback_query( + event.id, text=self.error_text(exc), show_alert=True + ) + async def handle_follow_up(self, bot: Bot, event: MessageEvent) -> None: chat_key = self.chat_key(event) session = self.service.get_session(chat_key) diff --git a/src/nonebot_plugin_codex/telegram_commands.py b/src/nonebot_plugin_codex/telegram_commands.py index b40f09e..dfb27a8 100644 --- a/src/nonebot_plugin_codex/telegram_commands.py +++ b/src/nonebot_plugin_codex/telegram_commands.py @@ -28,6 +28,16 @@ class TelegramCommandSpec: description="打开使用引导面板", usage="/start", ), + TelegramCommandSpec( + name="panel", + description="打开当前工作台", + usage="/panel", + ), + TelegramCommandSpec( + name="status", + description="打开当前工作台", + usage="/status", + ), TelegramCommandSpec( name="mode", description="查看或切换默认模式", diff --git a/tests/test_service.py b/tests/test_service.py index 892d5d0..417f0d0 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -451,6 +451,56 @@ def test_render_setting_panels_show_expected_headings( assert markup.inline_keyboard +def test_render_workspace_panel_shows_current_state_and_recent_history( + tmp_path: Path, + model_cache_file: Path, +) -> None: + service = make_service(tmp_path, model_cache_file) + workdir = tmp_path / "workspace" + workdir.mkdir() + service.preference_overrides["private_1"] = service._default_preferences() # noqa: SLF001 + service.preference_overrides["private_1"].workdir = str(workdir.resolve()) + session = service.activate_chat("private_1") + session.active_mode = "exec" + session.exec_thread_id = "exec-1" + session.thread_id = "exec-1" + write_history_session( + tmp_path, + session_id="exec-1", + thread_name="Recent Session", + assistant_text="assistant world", + ) + + service.open_workspace_panel("private_1") + text, markup = service.render_workspace_panel("private_1") + + assert "当前工作台" in text + assert "当前模式:exec" in text + assert "模型: gpt-5 | 推理: xhigh | 权限: safe" in text + assert f"当前工作目录:{workdir.resolve()}" in text + assert "当前会话:exec | exec-1" in text + assert "Recent Session" in text + assert markup.inline_keyboard + + +def test_navigate_workspace_panel_refresh_reuses_token_and_bumps_version( + tmp_path: Path, + model_cache_file: Path, +) -> None: + service = make_service(tmp_path, model_cache_file) + + panel = service.open_workspace_panel("private_1") + refreshed = service.navigate_workspace_panel( + "private_1", + panel.token, + panel.version, + "refresh", + ) + + assert refreshed.token == panel.token + assert refreshed.version == panel.version + 1 + + @pytest.mark.asyncio async def test_apply_permission_setting_panel_updates_preference( tmp_path: Path, model_cache_file: Path diff --git a/tests/test_telegram_commands.py b/tests/test_telegram_commands.py index 150852a..f9c18a6 100644 --- a/tests/test_telegram_commands.py +++ b/tests/test_telegram_commands.py @@ -12,6 +12,8 @@ def test_build_telegram_commands_uses_expected_order_and_chinese_descriptions() "codex", "help", "start", + "panel", + "status", "mode", "exec", "new", @@ -30,6 +32,8 @@ def test_build_telegram_commands_uses_expected_order_and_chinese_descriptions() {"command": "codex", "description": "连接 Codex 并可附带首条任务"}, {"command": "help", "description": "打开使用引导面板"}, {"command": "start", "description": "打开使用引导面板"}, + {"command": "panel", "description": "打开当前工作台"}, + {"command": "status", "description": "打开当前工作台"}, {"command": "mode", "description": "查看或切换默认模式"}, {"command": "exec", "description": "以一次性 exec 模式执行任务"}, {"command": "new", "description": "新建当前聊天会话"}, @@ -47,6 +51,7 @@ def test_build_telegram_commands_uses_expected_order_and_chinese_descriptions() def test_build_plugin_usage_lists_all_commands() -> None: assert build_plugin_usage() == ( - "/codex [prompt], /help, /start, /mode, /exec, /new, /stop, /models, " - "/model, /effort, /permission, /pwd, /cd, /home, /sessions" + "/codex [prompt], /help, /start, /panel, /status, /mode, /exec, /new, " + "/stop, /models, /model, /effort, /permission, /pwd, /cd, /home, " + "/sessions" ) diff --git a/tests/test_telegram_handlers.py b/tests/test_telegram_handlers.py index c83d592..02acc6e 100644 --- a/tests/test_telegram_handlers.py +++ b/tests/test_telegram_handlers.py @@ -16,6 +16,7 @@ encode_browser_callback, encode_history_callback, encode_setting_callback, + encode_workspace_callback, ) @@ -146,6 +147,8 @@ def __init__(self) -> None: self.setting_text = "模式设置" self.onboarding_text = "开始使用 Codex" self.onboarding_markup = SimpleNamespace(name="onboarding") + self.workspace_text = "当前工作台" + self.workspace_markup = SimpleNamespace(name="workspace") self.default_mode = "resume" self.execute_calls: list[tuple[str, str | None]] = [] self.browser_token = "token" @@ -162,6 +165,9 @@ def __init__(self) -> None: self.onboarding_token = "onboarding" self.onboarding_version = 1 self.onboarding_closed = False + self.workspace_token = "workspace" + self.workspace_version = 1 + self.workspace_closed = False def get_session(self, chat_key: str) -> ChatSession: return self.session @@ -362,6 +368,49 @@ def get_onboarding_panel( def close_onboarding_panel(self, chat_key: str, token: str, version: int) -> None: self.onboarding_closed = True + def open_workspace_panel(self, chat_key: str) -> SimpleNamespace: + return SimpleNamespace(token=self.workspace_token) + + def render_workspace_panel(self, chat_key: str) -> tuple[str, Any]: + return self.workspace_text, self.workspace_markup + + def remember_workspace_panel_message( + self, chat_key: str, token: str, message_id: int | None + ) -> None: + return None + + def get_workspace_panel( + self, + chat_key: str, + token: str | None = None, + version: int | None = None, + ) -> SimpleNamespace: + if token is not None and token != self.workspace_token: + raise ValueError("工作台面板已失效,请重新执行 /panel") + if version is not None and version != self.workspace_version: + raise ValueError("工作台面板已失效,请重新执行 /panel") + return SimpleNamespace( + token=self.workspace_token, + version=self.workspace_version, + message_id=1, + ) + + def close_workspace_panel(self, chat_key: str, token: str, version: int) -> None: + self.workspace_closed = True + + def navigate_workspace_panel( + self, + chat_key: str, + token: str, + version: int, + action: str, + ) -> SimpleNamespace: + return SimpleNamespace( + token=self.workspace_token, + version=self.workspace_version, + message_id=1, + ) + def make_real_service( tmp_path: Path, @@ -527,6 +576,143 @@ async def test_handle_sessions_opens_history_browser() -> None: assert bot.sent[0]["text"] == "Codex 历史会话" +@pytest.mark.asyncio +@pytest.mark.parametrize("handler_name", ["handle_panel", "handle_status"]) +async def test_panel_and_status_open_workspace_panel(handler_name: str) -> None: + service = FakeService() + handlers = TelegramHandlers(service) + bot = FakeBot() + + await getattr(handlers, handler_name)(bot, FakeEvent("")) + + assert bot.sent[0]["text"] == "当前工作台" + assert bot.sent[0]["reply_markup"] is service.workspace_markup + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("action", "expected_text"), + [ + ("mode", "模式设置"), + ("model", "模型设置"), + ("effort", "推理强度设置"), + ("permission", "权限模式设置"), + ("browse", "目录浏览"), + ("history", "Codex 历史会话"), + ], +) +async def test_handle_workspace_callback_opens_existing_panels( + action: str, + expected_text: str, +) -> None: + service = FakeService() + handlers = TelegramHandlers(service) + bot = FakeBot() + event = FakeCallbackEvent( + encode_workspace_callback( + service.workspace_token, + service.workspace_version, + action, + ) + ) + + await handlers.handle_workspace_callback(bot, event) + + assert bot.sent[0]["text"] == expected_text + + +@pytest.mark.asyncio +async def test_handle_workspace_callback_new_resets_chat() -> None: + service = FakeService() + service.session.thread_id = "thread-1" + handlers = TelegramHandlers(service) + bot = FakeBot() + event = FakeCallbackEvent( + encode_workspace_callback( + service.workspace_token, + service.workspace_version, + "new", + ) + ) + + await handlers.handle_workspace_callback(bot, event) + + assert "已清空当前 Codex 会话" in bot.sent[0]["text"] + assert bot.answered[0]["text"] == "已新开会话。" + + +@pytest.mark.asyncio +async def test_handle_workspace_callback_stop_disconnects_chat() -> None: + service = FakeService() + service.session.active = True + service.session.thread_id = "thread-1" + handlers = TelegramHandlers(service) + bot = FakeBot() + event = FakeCallbackEvent( + encode_workspace_callback( + service.workspace_token, + service.workspace_version, + "stop", + ) + ) + + await handlers.handle_workspace_callback(bot, event) + + assert bot.sent[0]["text"] == "已断开当前聊天窗口的 Codex 会话。" + assert bot.answered[0]["text"] == "已停止。" + + +@pytest.mark.asyncio +async def test_handle_workspace_callback_refresh_rerenders_panel() -> None: + service = FakeService() + handlers = TelegramHandlers(service) + bot = FakeBot() + event = FakeCallbackEvent( + encode_workspace_callback( + service.workspace_token, + service.workspace_version, + "refresh", + ) + ) + + await handlers.handle_workspace_callback(bot, event) + + assert bot.edited[0]["text"] == "当前工作台" + + +@pytest.mark.asyncio +async def test_handle_workspace_callback_close_closes_panel() -> None: + service = FakeService() + handlers = TelegramHandlers(service) + bot = FakeBot() + event = FakeCallbackEvent( + encode_workspace_callback( + service.workspace_token, + service.workspace_version, + "close", + ) + ) + + await handlers.handle_workspace_callback(bot, event) + + assert service.workspace_closed is True + assert bot.edited[0]["text"] == "工作台已关闭。" + assert bot.answered[0]["text"] == "已关闭。" + + +@pytest.mark.asyncio +async def test_handle_workspace_callback_rejects_stale_payload() -> None: + handlers = TelegramHandlers(FakeService()) + bot = FakeBot() + + await handlers.handle_workspace_callback( + bot, FakeCallbackEvent("cwp:stale:1:browse") + ) + + assert bot.answered[0]["text"] == "工作台面板已失效,请重新执行 /panel" + assert bot.answered[0]["show_alert"] is True + + @pytest.mark.asyncio @pytest.mark.parametrize( ("payload", "expected_text"),