nonebot-plugin-codex 0.1.3__tar.gz → 0.1.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (24) hide show
  1. {nonebot_plugin_codex-0.1.3 → nonebot_plugin_codex-0.1.4}/PKG-INFO +8 -1
  2. {nonebot_plugin_codex-0.1.3 → nonebot_plugin_codex-0.1.4}/README.md +7 -0
  3. {nonebot_plugin_codex-0.1.3 → nonebot_plugin_codex-0.1.4}/pyproject.toml +1 -1
  4. {nonebot_plugin_codex-0.1.3 → nonebot_plugin_codex-0.1.4}/src/nonebot_plugin_codex/__init__.py +22 -0
  5. {nonebot_plugin_codex-0.1.3 → nonebot_plugin_codex-0.1.4}/src/nonebot_plugin_codex/service.py +215 -0
  6. {nonebot_plugin_codex-0.1.3 → nonebot_plugin_codex-0.1.4}/src/nonebot_plugin_codex/telegram.py +132 -0
  7. {nonebot_plugin_codex-0.1.3 → nonebot_plugin_codex-0.1.4}/src/nonebot_plugin_codex/telegram_commands.py +10 -0
  8. {nonebot_plugin_codex-0.1.3 → nonebot_plugin_codex-0.1.4}/tests/test_release_notes.py +2 -1
  9. {nonebot_plugin_codex-0.1.3 → nonebot_plugin_codex-0.1.4}/tests/test_service.py +50 -0
  10. {nonebot_plugin_codex-0.1.3 → nonebot_plugin_codex-0.1.4}/tests/test_telegram_commands.py +7 -2
  11. {nonebot_plugin_codex-0.1.3 → nonebot_plugin_codex-0.1.4}/tests/test_telegram_handlers.py +186 -0
  12. {nonebot_plugin_codex-0.1.3 → nonebot_plugin_codex-0.1.4}/LICENSE +0 -0
  13. {nonebot_plugin_codex-0.1.3 → nonebot_plugin_codex-0.1.4}/src/nonebot_plugin_codex/config.py +0 -0
  14. {nonebot_plugin_codex-0.1.3 → nonebot_plugin_codex-0.1.4}/src/nonebot_plugin_codex/native_client.py +0 -0
  15. {nonebot_plugin_codex-0.1.3 → nonebot_plugin_codex-0.1.4}/src/nonebot_plugin_codex/runtime.py +0 -0
  16. {nonebot_plugin_codex-0.1.3 → nonebot_plugin_codex-0.1.4}/src/nonebot_plugin_codex/telegram_rendering.py +0 -0
  17. {nonebot_plugin_codex-0.1.3 → nonebot_plugin_codex-0.1.4}/tests/__init__.py +0 -0
  18. {nonebot_plugin_codex-0.1.3 → nonebot_plugin_codex-0.1.4}/tests/conftest.py +0 -0
  19. {nonebot_plugin_codex-0.1.3 → nonebot_plugin_codex-0.1.4}/tests/test_config.py +0 -0
  20. {nonebot_plugin_codex-0.1.3 → nonebot_plugin_codex-0.1.4}/tests/test_native_client.py +0 -0
  21. {nonebot_plugin_codex-0.1.3 → nonebot_plugin_codex-0.1.4}/tests/test_plugin_entry.py +0 -0
  22. {nonebot_plugin_codex-0.1.3 → nonebot_plugin_codex-0.1.4}/tests/test_plugin_meta.py +0 -0
  23. {nonebot_plugin_codex-0.1.3 → nonebot_plugin_codex-0.1.4}/tests/test_runtime.py +0 -0
  24. {nonebot_plugin_codex-0.1.3 → nonebot_plugin_codex-0.1.4}/tests/test_telegram_rendering.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: nonebot-plugin-codex
3
- Version: 0.1.3
3
+ Version: 0.1.4
4
4
  Summary: Telegram bridge plugin for driving Codex from NoneBot
5
5
  Author-Email: ttiee <469784630@qq.com>
6
6
  License: GPL-3.0-or-later
@@ -122,6 +122,7 @@ codex_workdir = "/home/yourname"
122
122
 
123
123
  ```text
124
124
  /codex
125
+ /panel
125
126
  /cd /home/yourname/projects/demo
126
127
  /mode resume
127
128
  然后继续直接发送普通文本消息续聊
@@ -129,6 +130,8 @@ codex_workdir = "/home/yourname"
129
130
 
130
131
  `/codex` 不带参数时会打开一个 Telegram 内的使用引导面板,方便你直接查看当前模式、工作目录、设置摘要,并进入目录浏览、设置面板或历史会话。
131
132
 
133
+ `/panel` 和 `/status` 会打开统一的“当前工作台”面板,把模式、模型、推理强度、权限、工作目录、当前会话状态和最近历史摘要放在同一屏里,并提供进入设置、目录、历史、新会话和停止会话的快捷操作。
134
+
132
135
  你也可以直接把首条任务跟在 `/codex` 后面:
133
136
 
134
137
  ```text
@@ -147,6 +150,7 @@ codex_workdir = "/home/yourname"
147
150
  ```text
148
151
  /help
149
152
  /start
153
+ /panel
150
154
  ```
151
155
 
152
156
  ## 配置说明
@@ -196,6 +200,8 @@ codex_stream_read_limit = 1048576
196
200
  | `/codex [prompt]` | 打开引导面板,或直接附带首条任务连接 Codex |
197
201
  | `/help` | 打开使用引导面板 |
198
202
  | `/start` | 打开使用引导面板 |
203
+ | `/panel` | 打开统一工作台面板 |
204
+ | `/status` | 打开统一工作台面板 |
199
205
  | `/mode [resume\|exec]` | 查看或切换默认模式 |
200
206
  | `/exec <prompt>` | 以一次性 `exec` 模式执行任务 |
201
207
  | `/new` | 新建当前聊天会话 |
@@ -229,6 +235,7 @@ codex_stream_read_limit = 1048576
229
235
 
230
236
  ## 目录与历史会话
231
237
 
238
+ - `/panel` 或 `/status` 会打开统一工作台,一屏查看当前设置、工作目录、会话状态和最近历史,并跳转到常用控制面板。
232
239
  - `/cd` 可打开目录浏览器,逐级进入目录、切换 Home、显示隐藏目录,并把当前浏览目录设置为工作目录。
233
240
  - `/sessions` 会列出 native 与 exec 历史会话,便于恢复此前任务。
234
241
  - 历史会话恢复时会尝试切回原始工作目录;如果原目录不存在,会保留当前目录并给出提示。
@@ -109,6 +109,7 @@ codex_workdir = "/home/yourname"
109
109
 
110
110
  ```text
111
111
  /codex
112
+ /panel
112
113
  /cd /home/yourname/projects/demo
113
114
  /mode resume
114
115
  然后继续直接发送普通文本消息续聊
@@ -116,6 +117,8 @@ codex_workdir = "/home/yourname"
116
117
 
117
118
  `/codex` 不带参数时会打开一个 Telegram 内的使用引导面板,方便你直接查看当前模式、工作目录、设置摘要,并进入目录浏览、设置面板或历史会话。
118
119
 
120
+ `/panel` 和 `/status` 会打开统一的“当前工作台”面板,把模式、模型、推理强度、权限、工作目录、当前会话状态和最近历史摘要放在同一屏里,并提供进入设置、目录、历史、新会话和停止会话的快捷操作。
121
+
119
122
  你也可以直接把首条任务跟在 `/codex` 后面:
120
123
 
121
124
  ```text
@@ -134,6 +137,7 @@ codex_workdir = "/home/yourname"
134
137
  ```text
135
138
  /help
136
139
  /start
140
+ /panel
137
141
  ```
138
142
 
139
143
  ## 配置说明
@@ -183,6 +187,8 @@ codex_stream_read_limit = 1048576
183
187
  | `/codex [prompt]` | 打开引导面板,或直接附带首条任务连接 Codex |
184
188
  | `/help` | 打开使用引导面板 |
185
189
  | `/start` | 打开使用引导面板 |
190
+ | `/panel` | 打开统一工作台面板 |
191
+ | `/status` | 打开统一工作台面板 |
186
192
  | `/mode [resume\|exec]` | 查看或切换默认模式 |
187
193
  | `/exec <prompt>` | 以一次性 `exec` 模式执行任务 |
188
194
  | `/new` | 新建当前聊天会话 |
@@ -216,6 +222,7 @@ codex_stream_read_limit = 1048576
216
222
 
217
223
  ## 目录与历史会话
218
224
 
225
+ - `/panel` 或 `/status` 会打开统一工作台,一屏查看当前设置、工作目录、会话状态和最近历史,并跳转到常用控制面板。
219
226
  - `/cd` 可打开目录浏览器,逐级进入目录、切换 Home、显示隐藏目录,并把当前浏览目录设置为工作目录。
220
227
  - `/sessions` 会列出 native 与 exec 历史会话,便于恢复此前任务。
221
228
  - 历史会话恢复时会尝试切回原始工作目录;如果原目录不存在,会保留当前目录并给出提示。
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "nonebot-plugin-codex"
3
- version = "0.1.3"
3
+ version = "0.1.4"
4
4
  description = "Telegram bridge plugin for driving Codex from NoneBot"
5
5
  authors = [
6
6
  { name = "ttiee", email = "469784630@qq.com" },
@@ -88,6 +88,8 @@ if _runtime_ready:
88
88
  codex_cmd = on_command("codex", priority=10, block=True)
89
89
  help_cmd = on_command("help", priority=10, block=True)
90
90
  start_cmd = on_command("start", priority=10, block=True)
91
+ panel_cmd = on_command("panel", priority=10, block=True)
92
+ status_cmd = on_command("status", priority=10, block=True)
91
93
  mode_cmd = on_command("mode", priority=10, block=True)
92
94
  exec_cmd = on_command("exec", priority=10, block=True)
93
95
  new_cmd = on_command("new", priority=10, block=True)
@@ -125,6 +127,12 @@ if _runtime_ready:
125
127
  block=True,
126
128
  rule=handlers.is_onboarding_callback,
127
129
  )
130
+ workspace_callback = on_type(
131
+ CallbackQueryEvent,
132
+ priority=10,
133
+ block=True,
134
+ rule=handlers.is_workspace_callback,
135
+ )
128
136
 
129
137
  @codex_cmd.handle()
130
138
  async def _handle_codex(
@@ -140,6 +148,14 @@ if _runtime_ready:
140
148
  async def _handle_start(bot: Bot, event: MessageEvent) -> None:
141
149
  await handlers.handle_start(bot, event)
142
150
 
151
+ @panel_cmd.handle()
152
+ async def _handle_panel(bot: Bot, event: MessageEvent) -> None:
153
+ await handlers.handle_panel(bot, event)
154
+
155
+ @status_cmd.handle()
156
+ async def _handle_status(bot: Bot, event: MessageEvent) -> None:
157
+ await handlers.handle_status(bot, event)
158
+
143
159
  @mode_cmd.handle()
144
160
  async def _handle_mode(
145
161
  bot: Bot, event: MessageEvent, args: Message = CommandArg()
@@ -220,6 +236,12 @@ if _runtime_ready:
220
236
  ) -> None:
221
237
  await handlers.handle_onboarding_callback(bot, event)
222
238
 
239
+ @workspace_callback.handle()
240
+ async def _handle_workspace_callback(
241
+ bot: Bot, event: CallbackQueryEvent
242
+ ) -> None:
243
+ await handlers.handle_workspace_callback(bot, event)
244
+
223
245
  @follow_up.handle()
224
246
  async def _handle_follow_up(bot: Bot, event: MessageEvent) -> None:
225
247
  await handlers.handle_follow_up(bot, event)
@@ -40,6 +40,8 @@ SETTING_STALE_MESSAGE = "设置面板已失效,请重新执行对应命令"
40
40
  SUPPORTED_SETTING_PANELS = {"mode", "model", "effort", "permission"}
41
41
  ONBOARDING_CALLBACK_PREFIX = "cop"
42
42
  ONBOARDING_STALE_MESSAGE = "引导面板已失效,请重新执行 /codex"
43
+ WORKSPACE_CALLBACK_PREFIX = "cwp"
44
+ WORKSPACE_STALE_MESSAGE = "工作台面板已失效,请重新执行 /panel"
43
45
 
44
46
 
45
47
  @dataclass(slots=True)
@@ -205,6 +207,14 @@ class OnboardingPanelState:
205
207
  message_id: int | None = None
206
208
 
207
209
 
210
+ @dataclass(slots=True)
211
+ class WorkspacePanelState:
212
+ chat_key: str
213
+ token: str
214
+ version: int
215
+ message_id: int | None = None
216
+
217
+
208
218
  def build_chat_key(chat_type: str, chat_id: int) -> str:
209
219
  if chat_type == "private":
210
220
  return f"private_{chat_id}"
@@ -356,6 +366,22 @@ def decode_onboarding_callback(payload: str) -> tuple[str, int, str]:
356
366
  return token, version, parts[3]
357
367
 
358
368
 
369
+ def encode_workspace_callback(token: str, version: int, action: str) -> str:
370
+ return f"{WORKSPACE_CALLBACK_PREFIX}:{token}:{version}:{action}"
371
+
372
+
373
+ def decode_workspace_callback(payload: str) -> tuple[str, int, str]:
374
+ parts = payload.split(":")
375
+ if len(parts) != 4 or parts[0] != WORKSPACE_CALLBACK_PREFIX:
376
+ raise ValueError("无效的工作台回调。")
377
+ token = parts[1]
378
+ try:
379
+ version = int(parts[2])
380
+ except ValueError as exc:
381
+ raise ValueError("无效的工作台回调。") from exc
382
+ return token, version, parts[3]
383
+
384
+
359
385
  def parse_event_line(line: str) -> dict[str, Any] | None:
360
386
  try:
361
387
  payload = json.loads(line)
@@ -528,6 +554,7 @@ class CodexBridgeService:
528
554
  self.history_browsers: dict[str, HistoryBrowserState] = {}
529
555
  self.setting_panels: dict[str, SettingPanelState] = {}
530
556
  self.onboarding_panels: dict[str, OnboardingPanelState] = {}
557
+ self.workspace_panels: dict[str, WorkspacePanelState] = {}
531
558
  self._native_history_entries: list[HistoricalSessionSummary] = []
532
559
  self._native_history_loaded = False
533
560
  self._history_log_cache: dict[str, HistoryLogCacheEntry] = {}
@@ -1715,6 +1742,194 @@ class CodexBridgeService:
1715
1742
  self.get_onboarding_panel(chat_key, token=token, version=version)
1716
1743
  self.onboarding_panels.pop(chat_key, None)
1717
1744
 
1745
+ def _replace_workspace_panel_state(
1746
+ self,
1747
+ chat_key: str,
1748
+ *,
1749
+ previous: WorkspacePanelState | None = None,
1750
+ ) -> WorkspacePanelState:
1751
+ state = WorkspacePanelState(
1752
+ chat_key=chat_key,
1753
+ token=previous.token if previous else self._make_browser_token(),
1754
+ version=(previous.version + 1) if previous else 1,
1755
+ message_id=previous.message_id if previous else None,
1756
+ )
1757
+ self.workspace_panels[chat_key] = state
1758
+ return state
1759
+
1760
+ def open_workspace_panel(self, chat_key: str) -> WorkspacePanelState:
1761
+ self.get_preferences(chat_key)
1762
+ return self._replace_workspace_panel_state(chat_key)
1763
+
1764
+ def get_workspace_panel(
1765
+ self,
1766
+ chat_key: str,
1767
+ token: str | None = None,
1768
+ version: int | None = None,
1769
+ ) -> WorkspacePanelState:
1770
+ state = self.workspace_panels.get(chat_key)
1771
+ if state is None:
1772
+ raise ValueError(WORKSPACE_STALE_MESSAGE)
1773
+ if token is not None and state.token != token:
1774
+ raise ValueError(WORKSPACE_STALE_MESSAGE)
1775
+ if version is not None and state.version != version:
1776
+ raise ValueError(WORKSPACE_STALE_MESSAGE)
1777
+ return state
1778
+
1779
+ def remember_workspace_panel_message(
1780
+ self,
1781
+ chat_key: str,
1782
+ token: str,
1783
+ message_id: int | None,
1784
+ ) -> None:
1785
+ if message_id is None:
1786
+ return
1787
+ panel = self.get_workspace_panel(chat_key, token=token)
1788
+ panel.message_id = message_id
1789
+
1790
+ def close_workspace_panel(self, chat_key: str, token: str, version: int) -> None:
1791
+ self.get_workspace_panel(chat_key, token=token, version=version)
1792
+ self.workspace_panels.pop(chat_key, None)
1793
+
1794
+ def _workspace_active_mode(self, chat_key: str, preferences: ChatPreferences) -> str:
1795
+ session = self.sessions.get(chat_key)
1796
+ if session is not None and session.active_mode in {"resume", "exec"}:
1797
+ return session.active_mode
1798
+ return preferences.default_mode
1799
+
1800
+ def _workspace_session_summary(self, chat_key: str) -> str:
1801
+ session = self.sessions.get(chat_key)
1802
+ if session is None:
1803
+ return "未开始"
1804
+ active_mode = (
1805
+ session.active_mode
1806
+ if session.active_mode in {"resume", "exec"}
1807
+ else "resume"
1808
+ )
1809
+ thread_id = (
1810
+ self._current_exec_thread_id(session)
1811
+ if active_mode == "exec"
1812
+ else session.native_thread_id or session.thread_id
1813
+ )
1814
+ if not thread_id and not session.active:
1815
+ return "未开始"
1816
+ if not thread_id:
1817
+ return f"{active_mode} | 未绑定"
1818
+ return f"{active_mode} | {thread_id}"
1819
+
1820
+ def _workspace_recent_history_lines(self) -> list[str]:
1821
+ try:
1822
+ entries = self.list_history_sessions()[:2]
1823
+ except ValueError:
1824
+ return ["最近历史:不可用"]
1825
+ if not entries:
1826
+ return ["最近历史:无"]
1827
+ lines = ["最近历史:"]
1828
+ for entry in entries:
1829
+ lines.append(
1830
+ "- "
1831
+ f"{entry.thread_name} | "
1832
+ f"{self._format_history_relative_time(entry.updated_at)}"
1833
+ )
1834
+ return lines
1835
+
1836
+ def navigate_workspace_panel(
1837
+ self,
1838
+ chat_key: str,
1839
+ token: str,
1840
+ version: int,
1841
+ action: str,
1842
+ ) -> WorkspacePanelState:
1843
+ panel = self.get_workspace_panel(chat_key, token=token, version=version)
1844
+ if action != "refresh":
1845
+ raise ValueError("未知工作台操作。")
1846
+ return self._replace_workspace_panel_state(chat_key, previous=panel)
1847
+
1848
+ def render_workspace_panel(
1849
+ self, chat_key: str
1850
+ ) -> tuple[str, InlineKeyboardMarkup]:
1851
+ panel = self.get_workspace_panel(chat_key)
1852
+ preferences = self.get_preferences(chat_key)
1853
+ lines = [
1854
+ "当前工作台",
1855
+ f"当前模式:{self._workspace_active_mode(chat_key, preferences)}",
1856
+ f"当前设置:{format_preferences_summary(preferences)}",
1857
+ f"当前工作目录:{preferences.workdir}",
1858
+ f"当前会话:{self._workspace_session_summary(chat_key)}",
1859
+ *self._workspace_recent_history_lines(),
1860
+ ]
1861
+ keyboard = [
1862
+ [
1863
+ InlineKeyboardButton(
1864
+ text="模式",
1865
+ callback_data=encode_workspace_callback(
1866
+ panel.token, panel.version, "mode"
1867
+ ),
1868
+ ),
1869
+ InlineKeyboardButton(
1870
+ text="模型",
1871
+ callback_data=encode_workspace_callback(
1872
+ panel.token, panel.version, "model"
1873
+ ),
1874
+ ),
1875
+ InlineKeyboardButton(
1876
+ text="强度",
1877
+ callback_data=encode_workspace_callback(
1878
+ panel.token, panel.version, "effort"
1879
+ ),
1880
+ ),
1881
+ InlineKeyboardButton(
1882
+ text="权限",
1883
+ callback_data=encode_workspace_callback(
1884
+ panel.token, panel.version, "permission"
1885
+ ),
1886
+ ),
1887
+ ],
1888
+ [
1889
+ InlineKeyboardButton(
1890
+ text="目录",
1891
+ callback_data=encode_workspace_callback(
1892
+ panel.token, panel.version, "browse"
1893
+ ),
1894
+ ),
1895
+ InlineKeyboardButton(
1896
+ text="历史",
1897
+ callback_data=encode_workspace_callback(
1898
+ panel.token, panel.version, "history"
1899
+ ),
1900
+ ),
1901
+ ],
1902
+ [
1903
+ InlineKeyboardButton(
1904
+ text="新会话",
1905
+ callback_data=encode_workspace_callback(
1906
+ panel.token, panel.version, "new"
1907
+ ),
1908
+ ),
1909
+ InlineKeyboardButton(
1910
+ text="停止",
1911
+ callback_data=encode_workspace_callback(
1912
+ panel.token, panel.version, "stop"
1913
+ ),
1914
+ ),
1915
+ ],
1916
+ [
1917
+ InlineKeyboardButton(
1918
+ text="刷新",
1919
+ callback_data=encode_workspace_callback(
1920
+ panel.token, panel.version, "refresh"
1921
+ ),
1922
+ ),
1923
+ InlineKeyboardButton(
1924
+ text="关闭",
1925
+ callback_data=encode_workspace_callback(
1926
+ panel.token, panel.version, "close"
1927
+ ),
1928
+ ),
1929
+ ],
1930
+ ]
1931
+ return "\n".join(lines), InlineKeyboardMarkup(inline_keyboard=keyboard)
1932
+
1718
1933
  def render_onboarding_panel(
1719
1934
  self, chat_key: str
1720
1935
  ) -> tuple[str, InlineKeyboardMarkup]:
@@ -19,10 +19,13 @@ from .service import (
19
19
  ONBOARDING_CALLBACK_PREFIX,
20
20
  SETTING_STALE_MESSAGE,
21
21
  SETTING_CALLBACK_PREFIX,
22
+ WORKSPACE_STALE_MESSAGE,
23
+ WORKSPACE_CALLBACK_PREFIX,
22
24
  CodexBridgeService,
23
25
  chunk_text,
24
26
  build_chat_key,
25
27
  decode_onboarding_callback,
28
+ decode_workspace_callback,
26
29
  format_result_text,
27
30
  decode_browser_callback,
28
31
  decode_history_callback,
@@ -354,6 +357,11 @@ class TelegramHandlers:
354
357
  f"{ONBOARDING_CALLBACK_PREFIX}:"
355
358
  )
356
359
 
360
+ async def is_workspace_callback(self, event: CallbackQueryEvent) -> bool:
361
+ return isinstance(event.data, str) and event.data.startswith(
362
+ f"{WORKSPACE_CALLBACK_PREFIX}:"
363
+ )
364
+
357
365
  def callback_message_id(self, event: CallbackQueryEvent) -> int | None:
358
366
  message = getattr(event, "message", None)
359
367
  return getattr(message, "message_id", None)
@@ -446,6 +454,18 @@ class TelegramHandlers:
446
454
  getattr(message, "message_id", None),
447
455
  )
448
456
 
457
+ async def send_workspace_panel(
458
+ self, bot: Bot, event: MessageEvent, chat_key: str
459
+ ) -> None:
460
+ panel = self.service.open_workspace_panel(chat_key)
461
+ text, markup = self.service.render_workspace_panel(chat_key)
462
+ message = await self.send_event_message(bot, event, text, reply_markup=markup)
463
+ self.service.remember_workspace_panel_message(
464
+ chat_key,
465
+ panel.token,
466
+ getattr(message, "message_id", None),
467
+ )
468
+
449
469
  async def edit_or_resend_browser(
450
470
  self,
451
471
  bot: Bot,
@@ -547,6 +567,39 @@ class TelegramHandlers:
547
567
  getattr(message, "message_id", None),
548
568
  )
549
569
 
570
+ async def edit_or_resend_workspace_panel(
571
+ self,
572
+ bot: Bot,
573
+ event: CallbackQueryEvent,
574
+ chat_key: str,
575
+ ) -> None:
576
+ panel = self.service.get_workspace_panel(chat_key)
577
+ text, markup = self.service.render_workspace_panel(chat_key)
578
+ message_id = self.callback_message_id(event) or panel.message_id
579
+ chat_id = self.event_chat(event).id
580
+ try:
581
+ if message_id is None:
582
+ raise ValueError("missing message id")
583
+ await self.edit_message(
584
+ bot,
585
+ chat_id=chat_id,
586
+ message_id=message_id,
587
+ text=text,
588
+ reply_markup=markup,
589
+ )
590
+ self.service.remember_workspace_panel_message(
591
+ chat_key, panel.token, message_id
592
+ )
593
+ except Exception:
594
+ message = await self.send_chat_message(
595
+ bot, chat_id, text, reply_markup=markup
596
+ )
597
+ self.service.remember_workspace_panel_message(
598
+ chat_key,
599
+ panel.token,
600
+ getattr(message, "message_id", None),
601
+ )
602
+
550
603
  async def handle_codex(self, bot: Bot, event: MessageEvent, args: Message) -> None:
551
604
  chat_key = self.chat_key(event)
552
605
  session = self.service.activate_chat(chat_key)
@@ -569,6 +622,12 @@ class TelegramHandlers:
569
622
  async def handle_start(self, bot: Bot, event: MessageEvent) -> None:
570
623
  await self.send_onboarding_panel(bot, event, self.chat_key(event))
571
624
 
625
+ async def handle_panel(self, bot: Bot, event: MessageEvent) -> None:
626
+ await self.send_workspace_panel(bot, event, self.chat_key(event))
627
+
628
+ async def handle_status(self, bot: Bot, event: MessageEvent) -> None:
629
+ await self.send_workspace_panel(bot, event, self.chat_key(event))
630
+
572
631
  async def handle_mode(self, bot: Bot, event: MessageEvent, args: Message) -> None:
573
632
  chat_key = self.chat_key(event)
574
633
  mode = args.extract_plain_text().strip()
@@ -890,6 +949,79 @@ class TelegramHandlers:
890
949
  event.id, text=self.error_text(exc), show_alert=True
891
950
  )
892
951
 
952
+ async def handle_workspace_callback(
953
+ self, bot: Bot, event: CallbackQueryEvent
954
+ ) -> None:
955
+ if not isinstance(event.data, str):
956
+ await bot.answer_callback_query(
957
+ event.id, text=WORKSPACE_STALE_MESSAGE, show_alert=True
958
+ )
959
+ return
960
+
961
+ try:
962
+ chat_key = self.chat_key(event)
963
+ chat_id = self.event_chat(event).id
964
+ token, version, action = decode_workspace_callback(event.data)
965
+ self.service.get_workspace_panel(chat_key, token=token, version=version)
966
+ if action in {"mode", "model", "effort", "permission"}:
967
+ await self.send_setting_panel_to_chat(bot, chat_id, chat_key, action)
968
+ await bot.answer_callback_query(event.id)
969
+ return
970
+ if action == "browse":
971
+ await self.send_browser_to_chat(bot, chat_id, chat_key)
972
+ await bot.answer_callback_query(event.id)
973
+ return
974
+ if action == "history":
975
+ await self.send_history_browser_to_chat(bot, chat_id, chat_key)
976
+ await bot.answer_callback_query(event.id)
977
+ return
978
+ if action == "new":
979
+ await self.service.reset_chat(chat_key, keep_active=True)
980
+ await self.send_chat_message(
981
+ bot,
982
+ chat_id,
983
+ (
984
+ "已清空当前 Codex 会话。下一条普通消息会按以下设置新开会话:\n"
985
+ f"{self.current_summary(chat_key)}"
986
+ ),
987
+ )
988
+ await bot.answer_callback_query(event.id, text="已新开会话。")
989
+ return
990
+ if action == "stop":
991
+ await self.service.reset_chat(chat_key, keep_active=False)
992
+ await self.send_chat_message(
993
+ bot, chat_id, "已断开当前聊天窗口的 Codex 会话。"
994
+ )
995
+ await bot.answer_callback_query(event.id, text="已停止。")
996
+ return
997
+ if action == "close":
998
+ self.service.close_workspace_panel(chat_key, token, version)
999
+ message_id = self.callback_message_id(event)
1000
+ if message_id is not None:
1001
+ await self.edit_message(
1002
+ bot,
1003
+ chat_id=chat_id,
1004
+ message_id=message_id,
1005
+ text="工作台已关闭。",
1006
+ reply_markup=None,
1007
+ )
1008
+ await bot.answer_callback_query(event.id, text="已关闭。")
1009
+ return
1010
+ self.service.navigate_workspace_panel(chat_key, token, version, action)
1011
+ await self.edit_or_resend_workspace_panel(bot, event, chat_key)
1012
+ await bot.answer_callback_query(event.id)
1013
+ except ValueError as exc:
1014
+ text = str(exc) or WORKSPACE_STALE_MESSAGE
1015
+ await bot.answer_callback_query(
1016
+ event.id,
1017
+ text=text,
1018
+ show_alert=text == WORKSPACE_STALE_MESSAGE,
1019
+ )
1020
+ except RuntimeError as exc:
1021
+ await bot.answer_callback_query(
1022
+ event.id, text=self.error_text(exc), show_alert=True
1023
+ )
1024
+
893
1025
  async def handle_follow_up(self, bot: Bot, event: MessageEvent) -> None:
894
1026
  chat_key = self.chat_key(event)
895
1027
  session = self.service.get_session(chat_key)
@@ -28,6 +28,16 @@ TELEGRAM_COMMAND_SPECS: tuple[TelegramCommandSpec, ...] = (
28
28
  description="打开使用引导面板",
29
29
  usage="/start",
30
30
  ),
31
+ TelegramCommandSpec(
32
+ name="panel",
33
+ description="打开当前工作台",
34
+ usage="/panel",
35
+ ),
36
+ TelegramCommandSpec(
37
+ name="status",
38
+ description="打开当前工作台",
39
+ usage="/status",
40
+ ),
31
41
  TelegramCommandSpec(
32
42
  name="mode",
33
43
  description="查看或切换默认模式",
@@ -20,7 +20,8 @@ def test_render_release_notes_groups_conventional_commits() -> None:
20
20
  ],
21
21
  )
22
22
 
23
- assert "# v0.1.2" in notes
23
+ assert notes.startswith("Released on ")
24
+ assert "# v0.1.2" not in notes
24
25
  assert (
25
26
  "Compare: https://github.com/ttiee/nonebot-plugin-codex/compare/"
26
27
  "v0.1.1...v0.1.2"
@@ -451,6 +451,56 @@ def test_render_setting_panels_show_expected_headings(
451
451
  assert markup.inline_keyboard
452
452
 
453
453
 
454
+ def test_render_workspace_panel_shows_current_state_and_recent_history(
455
+ tmp_path: Path,
456
+ model_cache_file: Path,
457
+ ) -> None:
458
+ service = make_service(tmp_path, model_cache_file)
459
+ workdir = tmp_path / "workspace"
460
+ workdir.mkdir()
461
+ service.preference_overrides["private_1"] = service._default_preferences() # noqa: SLF001
462
+ service.preference_overrides["private_1"].workdir = str(workdir.resolve())
463
+ session = service.activate_chat("private_1")
464
+ session.active_mode = "exec"
465
+ session.exec_thread_id = "exec-1"
466
+ session.thread_id = "exec-1"
467
+ write_history_session(
468
+ tmp_path,
469
+ session_id="exec-1",
470
+ thread_name="Recent Session",
471
+ assistant_text="assistant world",
472
+ )
473
+
474
+ service.open_workspace_panel("private_1")
475
+ text, markup = service.render_workspace_panel("private_1")
476
+
477
+ assert "当前工作台" in text
478
+ assert "当前模式:exec" in text
479
+ assert "模型: gpt-5 | 推理: xhigh | 权限: safe" in text
480
+ assert f"当前工作目录:{workdir.resolve()}" in text
481
+ assert "当前会话:exec | exec-1" in text
482
+ assert "Recent Session" in text
483
+ assert markup.inline_keyboard
484
+
485
+
486
+ def test_navigate_workspace_panel_refresh_reuses_token_and_bumps_version(
487
+ tmp_path: Path,
488
+ model_cache_file: Path,
489
+ ) -> None:
490
+ service = make_service(tmp_path, model_cache_file)
491
+
492
+ panel = service.open_workspace_panel("private_1")
493
+ refreshed = service.navigate_workspace_panel(
494
+ "private_1",
495
+ panel.token,
496
+ panel.version,
497
+ "refresh",
498
+ )
499
+
500
+ assert refreshed.token == panel.token
501
+ assert refreshed.version == panel.version + 1
502
+
503
+
454
504
  @pytest.mark.asyncio
455
505
  async def test_apply_permission_setting_panel_updates_preference(
456
506
  tmp_path: Path, model_cache_file: Path
@@ -12,6 +12,8 @@ def test_build_telegram_commands_uses_expected_order_and_chinese_descriptions()
12
12
  "codex",
13
13
  "help",
14
14
  "start",
15
+ "panel",
16
+ "status",
15
17
  "mode",
16
18
  "exec",
17
19
  "new",
@@ -30,6 +32,8 @@ def test_build_telegram_commands_uses_expected_order_and_chinese_descriptions()
30
32
  {"command": "codex", "description": "连接 Codex 并可附带首条任务"},
31
33
  {"command": "help", "description": "打开使用引导面板"},
32
34
  {"command": "start", "description": "打开使用引导面板"},
35
+ {"command": "panel", "description": "打开当前工作台"},
36
+ {"command": "status", "description": "打开当前工作台"},
33
37
  {"command": "mode", "description": "查看或切换默认模式"},
34
38
  {"command": "exec", "description": "以一次性 exec 模式执行任务"},
35
39
  {"command": "new", "description": "新建当前聊天会话"},
@@ -47,6 +51,7 @@ def test_build_telegram_commands_uses_expected_order_and_chinese_descriptions()
47
51
 
48
52
  def test_build_plugin_usage_lists_all_commands() -> None:
49
53
  assert build_plugin_usage() == (
50
- "/codex [prompt], /help, /start, /mode, /exec, /new, /stop, /models, "
51
- "/model, /effort, /permission, /pwd, /cd, /home, /sessions"
54
+ "/codex [prompt], /help, /start, /panel, /status, /mode, /exec, /new, "
55
+ "/stop, /models, /model, /effort, /permission, /pwd, /cd, /home, "
56
+ "/sessions"
52
57
  )
@@ -16,6 +16,7 @@ from nonebot_plugin_codex.service import (
16
16
  encode_browser_callback,
17
17
  encode_history_callback,
18
18
  encode_setting_callback,
19
+ encode_workspace_callback,
19
20
  )
20
21
 
21
22
 
@@ -146,6 +147,8 @@ class FakeService:
146
147
  self.setting_text = "模式设置"
147
148
  self.onboarding_text = "开始使用 Codex"
148
149
  self.onboarding_markup = SimpleNamespace(name="onboarding")
150
+ self.workspace_text = "当前工作台"
151
+ self.workspace_markup = SimpleNamespace(name="workspace")
149
152
  self.default_mode = "resume"
150
153
  self.execute_calls: list[tuple[str, str | None]] = []
151
154
  self.browser_token = "token"
@@ -162,6 +165,9 @@ class FakeService:
162
165
  self.onboarding_token = "onboarding"
163
166
  self.onboarding_version = 1
164
167
  self.onboarding_closed = False
168
+ self.workspace_token = "workspace"
169
+ self.workspace_version = 1
170
+ self.workspace_closed = False
165
171
 
166
172
  def get_session(self, chat_key: str) -> ChatSession:
167
173
  return self.session
@@ -362,6 +368,49 @@ class FakeService:
362
368
  def close_onboarding_panel(self, chat_key: str, token: str, version: int) -> None:
363
369
  self.onboarding_closed = True
364
370
 
371
+ def open_workspace_panel(self, chat_key: str) -> SimpleNamespace:
372
+ return SimpleNamespace(token=self.workspace_token)
373
+
374
+ def render_workspace_panel(self, chat_key: str) -> tuple[str, Any]:
375
+ return self.workspace_text, self.workspace_markup
376
+
377
+ def remember_workspace_panel_message(
378
+ self, chat_key: str, token: str, message_id: int | None
379
+ ) -> None:
380
+ return None
381
+
382
+ def get_workspace_panel(
383
+ self,
384
+ chat_key: str,
385
+ token: str | None = None,
386
+ version: int | None = None,
387
+ ) -> SimpleNamespace:
388
+ if token is not None and token != self.workspace_token:
389
+ raise ValueError("工作台面板已失效,请重新执行 /panel")
390
+ if version is not None and version != self.workspace_version:
391
+ raise ValueError("工作台面板已失效,请重新执行 /panel")
392
+ return SimpleNamespace(
393
+ token=self.workspace_token,
394
+ version=self.workspace_version,
395
+ message_id=1,
396
+ )
397
+
398
+ def close_workspace_panel(self, chat_key: str, token: str, version: int) -> None:
399
+ self.workspace_closed = True
400
+
401
+ def navigate_workspace_panel(
402
+ self,
403
+ chat_key: str,
404
+ token: str,
405
+ version: int,
406
+ action: str,
407
+ ) -> SimpleNamespace:
408
+ return SimpleNamespace(
409
+ token=self.workspace_token,
410
+ version=self.workspace_version,
411
+ message_id=1,
412
+ )
413
+
365
414
 
366
415
  def make_real_service(
367
416
  tmp_path: Path,
@@ -527,6 +576,143 @@ async def test_handle_sessions_opens_history_browser() -> None:
527
576
  assert bot.sent[0]["text"] == "Codex 历史会话"
528
577
 
529
578
 
579
+ @pytest.mark.asyncio
580
+ @pytest.mark.parametrize("handler_name", ["handle_panel", "handle_status"])
581
+ async def test_panel_and_status_open_workspace_panel(handler_name: str) -> None:
582
+ service = FakeService()
583
+ handlers = TelegramHandlers(service)
584
+ bot = FakeBot()
585
+
586
+ await getattr(handlers, handler_name)(bot, FakeEvent(""))
587
+
588
+ assert bot.sent[0]["text"] == "当前工作台"
589
+ assert bot.sent[0]["reply_markup"] is service.workspace_markup
590
+
591
+
592
+ @pytest.mark.asyncio
593
+ @pytest.mark.parametrize(
594
+ ("action", "expected_text"),
595
+ [
596
+ ("mode", "模式设置"),
597
+ ("model", "模型设置"),
598
+ ("effort", "推理强度设置"),
599
+ ("permission", "权限模式设置"),
600
+ ("browse", "目录浏览"),
601
+ ("history", "Codex 历史会话"),
602
+ ],
603
+ )
604
+ async def test_handle_workspace_callback_opens_existing_panels(
605
+ action: str,
606
+ expected_text: str,
607
+ ) -> None:
608
+ service = FakeService()
609
+ handlers = TelegramHandlers(service)
610
+ bot = FakeBot()
611
+ event = FakeCallbackEvent(
612
+ encode_workspace_callback(
613
+ service.workspace_token,
614
+ service.workspace_version,
615
+ action,
616
+ )
617
+ )
618
+
619
+ await handlers.handle_workspace_callback(bot, event)
620
+
621
+ assert bot.sent[0]["text"] == expected_text
622
+
623
+
624
+ @pytest.mark.asyncio
625
+ async def test_handle_workspace_callback_new_resets_chat() -> None:
626
+ service = FakeService()
627
+ service.session.thread_id = "thread-1"
628
+ handlers = TelegramHandlers(service)
629
+ bot = FakeBot()
630
+ event = FakeCallbackEvent(
631
+ encode_workspace_callback(
632
+ service.workspace_token,
633
+ service.workspace_version,
634
+ "new",
635
+ )
636
+ )
637
+
638
+ await handlers.handle_workspace_callback(bot, event)
639
+
640
+ assert "已清空当前 Codex 会话" in bot.sent[0]["text"]
641
+ assert bot.answered[0]["text"] == "已新开会话。"
642
+
643
+
644
+ @pytest.mark.asyncio
645
+ async def test_handle_workspace_callback_stop_disconnects_chat() -> None:
646
+ service = FakeService()
647
+ service.session.active = True
648
+ service.session.thread_id = "thread-1"
649
+ handlers = TelegramHandlers(service)
650
+ bot = FakeBot()
651
+ event = FakeCallbackEvent(
652
+ encode_workspace_callback(
653
+ service.workspace_token,
654
+ service.workspace_version,
655
+ "stop",
656
+ )
657
+ )
658
+
659
+ await handlers.handle_workspace_callback(bot, event)
660
+
661
+ assert bot.sent[0]["text"] == "已断开当前聊天窗口的 Codex 会话。"
662
+ assert bot.answered[0]["text"] == "已停止。"
663
+
664
+
665
+ @pytest.mark.asyncio
666
+ async def test_handle_workspace_callback_refresh_rerenders_panel() -> None:
667
+ service = FakeService()
668
+ handlers = TelegramHandlers(service)
669
+ bot = FakeBot()
670
+ event = FakeCallbackEvent(
671
+ encode_workspace_callback(
672
+ service.workspace_token,
673
+ service.workspace_version,
674
+ "refresh",
675
+ )
676
+ )
677
+
678
+ await handlers.handle_workspace_callback(bot, event)
679
+
680
+ assert bot.edited[0]["text"] == "当前工作台"
681
+
682
+
683
+ @pytest.mark.asyncio
684
+ async def test_handle_workspace_callback_close_closes_panel() -> None:
685
+ service = FakeService()
686
+ handlers = TelegramHandlers(service)
687
+ bot = FakeBot()
688
+ event = FakeCallbackEvent(
689
+ encode_workspace_callback(
690
+ service.workspace_token,
691
+ service.workspace_version,
692
+ "close",
693
+ )
694
+ )
695
+
696
+ await handlers.handle_workspace_callback(bot, event)
697
+
698
+ assert service.workspace_closed is True
699
+ assert bot.edited[0]["text"] == "工作台已关闭。"
700
+ assert bot.answered[0]["text"] == "已关闭。"
701
+
702
+
703
+ @pytest.mark.asyncio
704
+ async def test_handle_workspace_callback_rejects_stale_payload() -> None:
705
+ handlers = TelegramHandlers(FakeService())
706
+ bot = FakeBot()
707
+
708
+ await handlers.handle_workspace_callback(
709
+ bot, FakeCallbackEvent("cwp:stale:1:browse")
710
+ )
711
+
712
+ assert bot.answered[0]["text"] == "工作台面板已失效,请重新执行 /panel"
713
+ assert bot.answered[0]["show_alert"] is True
714
+
715
+
530
716
  @pytest.mark.asyncio
531
717
  @pytest.mark.parametrize(
532
718
  ("payload", "expected_text"),