windcode 0.1.0__py3-none-any.whl

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 (143) hide show
  1. windcode/__init__.py +6 -0
  2. windcode/__main__.py +4 -0
  3. windcode/auth/__init__.py +11 -0
  4. windcode/auth/store.py +90 -0
  5. windcode/cli.py +181 -0
  6. windcode/config/__init__.py +41 -0
  7. windcode/config/loader.py +117 -0
  8. windcode/config/models.py +203 -0
  9. windcode/config/writer.py +74 -0
  10. windcode/context/__init__.py +18 -0
  11. windcode/context/compactor.py +72 -0
  12. windcode/context/estimator.py +89 -0
  13. windcode/context/truncation.py +87 -0
  14. windcode/domain/__init__.py +1 -0
  15. windcode/domain/errors.py +33 -0
  16. windcode/domain/events.py +597 -0
  17. windcode/domain/messages.py +226 -0
  18. windcode/domain/models.py +73 -0
  19. windcode/domain/subagents.py +263 -0
  20. windcode/domain/tools.py +55 -0
  21. windcode/extensions/__init__.py +27 -0
  22. windcode/extensions/commands.py +25 -0
  23. windcode/extensions/discovery.py +139 -0
  24. windcode/extensions/events.py +58 -0
  25. windcode/extensions/hooks/__init__.py +4 -0
  26. windcode/extensions/hooks/dispatcher.py +107 -0
  27. windcode/extensions/hooks/executor.py +49 -0
  28. windcode/extensions/hooks/loader.py +101 -0
  29. windcode/extensions/hooks/models.py +109 -0
  30. windcode/extensions/mcp/__init__.py +10 -0
  31. windcode/extensions/mcp/adapter.py +101 -0
  32. windcode/extensions/mcp/catalog.py +153 -0
  33. windcode/extensions/mcp/client.py +273 -0
  34. windcode/extensions/mcp/runtime.py +144 -0
  35. windcode/extensions/mcp/tools.py +570 -0
  36. windcode/extensions/models.py +168 -0
  37. windcode/extensions/paths.py +71 -0
  38. windcode/extensions/plugins/__init__.py +3 -0
  39. windcode/extensions/plugins/installer.py +93 -0
  40. windcode/extensions/plugins/manifest.py +166 -0
  41. windcode/extensions/runtime.py +366 -0
  42. windcode/extensions/service.py +437 -0
  43. windcode/extensions/skills/__init__.py +3 -0
  44. windcode/extensions/skills/loader.py +67 -0
  45. windcode/extensions/skills/parser.py +57 -0
  46. windcode/extensions/skills/tools.py +213 -0
  47. windcode/extensions/snapshot.py +57 -0
  48. windcode/extensions/state.py +158 -0
  49. windcode/instructions/__init__.py +8 -0
  50. windcode/instructions/loader.py +50 -0
  51. windcode/memory/__init__.py +57 -0
  52. windcode/memory/extraction.py +83 -0
  53. windcode/memory/models.py +218 -0
  54. windcode/memory/refiner.py +189 -0
  55. windcode/memory/security.py +23 -0
  56. windcode/memory/service.py +179 -0
  57. windcode/memory/store.py +362 -0
  58. windcode/observability/__init__.py +4 -0
  59. windcode/observability/redaction.py +95 -0
  60. windcode/observability/trace.py +115 -0
  61. windcode/policy/__init__.py +22 -0
  62. windcode/policy/engine.py +137 -0
  63. windcode/policy/models.py +69 -0
  64. windcode/providers/__init__.py +29 -0
  65. windcode/providers/_utils.py +19 -0
  66. windcode/providers/anthropic.py +215 -0
  67. windcode/providers/base.py +46 -0
  68. windcode/providers/catalog.py +119 -0
  69. windcode/providers/errors.py +96 -0
  70. windcode/providers/openai_compat.py +231 -0
  71. windcode/providers/openai_responses.py +189 -0
  72. windcode/providers/registry.py +105 -0
  73. windcode/runtime/__init__.py +15 -0
  74. windcode/runtime/control.py +89 -0
  75. windcode/runtime/event_bus.py +67 -0
  76. windcode/runtime/loop.py +510 -0
  77. windcode/runtime/prompts.py +132 -0
  78. windcode/runtime/report.py +64 -0
  79. windcode/runtime/retry.py +73 -0
  80. windcode/runtime/scheduler.py +194 -0
  81. windcode/runtime/subagents/__init__.py +28 -0
  82. windcode/runtime/subagents/approvals.py +88 -0
  83. windcode/runtime/subagents/budgets.py +79 -0
  84. windcode/runtime/subagents/coordinator.py +714 -0
  85. windcode/runtime/subagents/factory.py +311 -0
  86. windcode/runtime/subagents/roles.py +74 -0
  87. windcode/runtime/subagents/verification.py +53 -0
  88. windcode/sandbox/__init__.py +3 -0
  89. windcode/sandbox/bwrap.py +79 -0
  90. windcode/sdk.py +1259 -0
  91. windcode/sessions/__init__.py +23 -0
  92. windcode/sessions/artifacts.py +69 -0
  93. windcode/sessions/models.py +104 -0
  94. windcode/sessions/store.py +167 -0
  95. windcode/sessions/tree.py +35 -0
  96. windcode/tools/__init__.py +10 -0
  97. windcode/tools/apply_patch.py +191 -0
  98. windcode/tools/ask_user.py +58 -0
  99. windcode/tools/builtins.py +44 -0
  100. windcode/tools/edit_file.py +54 -0
  101. windcode/tools/filesystem.py +77 -0
  102. windcode/tools/glob.py +35 -0
  103. windcode/tools/grep.py +66 -0
  104. windcode/tools/memory.py +400 -0
  105. windcode/tools/read_file.py +54 -0
  106. windcode/tools/registry.py +120 -0
  107. windcode/tools/shell.py +159 -0
  108. windcode/tools/subagents/__init__.py +31 -0
  109. windcode/tools/subagents/cancel.py +35 -0
  110. windcode/tools/subagents/integrate.py +54 -0
  111. windcode/tools/subagents/list.py +46 -0
  112. windcode/tools/subagents/spawn.py +86 -0
  113. windcode/tools/subagents/wait.py +74 -0
  114. windcode/tools/write_file.py +61 -0
  115. windcode/tui/__init__.py +3 -0
  116. windcode/tui/app.py +1015 -0
  117. windcode/tui/commands.py +90 -0
  118. windcode/tui/permission_display.py +24 -0
  119. windcode/tui/styles.tcss +591 -0
  120. windcode/tui/widgets/__init__.py +31 -0
  121. windcode/tui/widgets/approval.py +98 -0
  122. windcode/tui/widgets/command_menu.py +90 -0
  123. windcode/tui/widgets/extensions.py +48 -0
  124. windcode/tui/widgets/input.py +110 -0
  125. windcode/tui/widgets/memory.py +143 -0
  126. windcode/tui/widgets/messages.py +299 -0
  127. windcode/tui/widgets/models.py +565 -0
  128. windcode/tui/widgets/question.py +46 -0
  129. windcode/tui/widgets/sessions.py +68 -0
  130. windcode/tui/widgets/status.py +46 -0
  131. windcode/tui/widgets/subagents.py +195 -0
  132. windcode/tui/widgets/tools.py +66 -0
  133. windcode/tui/widgets/welcome.py +149 -0
  134. windcode/types.py +80 -0
  135. windcode/worktrees/__init__.py +24 -0
  136. windcode/worktrees/git.py +92 -0
  137. windcode/worktrees/manager.py +265 -0
  138. windcode/worktrees/models.py +62 -0
  139. windcode-0.1.0.dist-info/METADATA +207 -0
  140. windcode-0.1.0.dist-info/RECORD +143 -0
  141. windcode-0.1.0.dist-info/WHEEL +4 -0
  142. windcode-0.1.0.dist-info/entry_points.txt +2 -0
  143. windcode-0.1.0.dist-info/licenses/LICENSE +201 -0
windcode/tui/app.py ADDED
@@ -0,0 +1,1015 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from collections import deque
5
+ from pathlib import Path
6
+ from time import monotonic
7
+ from typing import ClassVar, Literal
8
+
9
+ from rich.text import Text as RichText
10
+ from textual import events, on
11
+ from textual.app import App, ComposeResult
12
+ from textual.binding import Binding, BindingType
13
+ from textual.containers import Vertical
14
+ from textual.css.query import NoMatches
15
+ from textual.theme import Theme
16
+ from textual.widgets import Static
17
+
18
+ from windcode.auth import CredentialStore, CredentialStoreError
19
+ from windcode.config import AppConfig, PermissionMode, ProviderConfig
20
+ from windcode.domain.events import (
21
+ ApprovalRequested,
22
+ ApprovalResponse,
23
+ SubagentEvent,
24
+ ToolFinished,
25
+ ToolProgress,
26
+ ToolStarted,
27
+ UserInputRequested,
28
+ UserResponse,
29
+ )
30
+ from windcode.domain.messages import TextBlock, message_from_dict
31
+ from windcode.memory import MemoryStatus
32
+ from windcode.sdk import RunHandle, Windcode
33
+ from windcode.tui.commands import (
34
+ COMMANDS,
35
+ CommandDefinition,
36
+ SkillDefinition,
37
+ SlashCommand,
38
+ complete_commands,
39
+ complete_skills,
40
+ parse_command,
41
+ )
42
+ from windcode.tui.widgets import (
43
+ ApprovalWidget,
44
+ ChatInput,
45
+ CommandMenu,
46
+ ExtensionList,
47
+ MemoryManager,
48
+ MessageStream,
49
+ ModelManager,
50
+ ProviderManager,
51
+ QuestionWidget,
52
+ SessionSelector,
53
+ StatusBar,
54
+ SubagentGroup,
55
+ ToolBlock,
56
+ WelcomeView,
57
+ )
58
+ from windcode.types import RunRequest
59
+
60
+
61
+ class WindcodeApp(App[None]):
62
+ ESCAPE_INTERRUPT_WINDOW = 1.5
63
+ CSS_PATH = "styles.tcss"
64
+ TITLE = "Windcode"
65
+ INLINE_PADDING = 0
66
+ BINDINGS: ClassVar[list[BindingType]] = [
67
+ ("ctrl+c", "cancel_or_quit", "取消"),
68
+ ("ctrl+q", "quit", "退出"),
69
+ ("ctrl+y", "copy_last_response", "复制回复"),
70
+ Binding("shift+tab", "cycle_permission_mode", "切换权限模式", priority=True),
71
+ Binding("backtab", "cycle_permission_mode", "切换权限模式", priority=True),
72
+ ]
73
+
74
+ def __init__(
75
+ self,
76
+ config: AppConfig,
77
+ *,
78
+ workspace: Path,
79
+ model: str | None = None,
80
+ session_id: str | None = None,
81
+ permission_mode: str | None = None,
82
+ state_root: Path | None = None,
83
+ config_file: Path | None = None,
84
+ credential_store: CredentialStore | None = None,
85
+ ) -> None:
86
+ super().__init__()
87
+ self.config = config
88
+ self.workspace = workspace
89
+ self.config_file = (
90
+ (config_file or self.workspace / ".windcode" / "config.toml").expanduser().resolve()
91
+ )
92
+ self.model = model
93
+ self.session_id = session_id
94
+ self.permission_mode = permission_mode or config.permission.mode.value
95
+ self.client = Windcode.open(
96
+ config,
97
+ state_root=state_root,
98
+ credential_store=credential_store,
99
+ workspace=workspace,
100
+ )
101
+ self.handle: RunHandle | None = None
102
+ self.tool_blocks: dict[str, ToolBlock] = {}
103
+ self.approval_widgets: dict[str, ApprovalWidget] = {}
104
+ self.session_selector: SessionSelector | None = None
105
+ self.model_manager: ModelManager | None = None
106
+ self.memory_manager: MemoryManager | None = None
107
+ self.provider_manager: ProviderManager | None = None
108
+ self.subagent_group: SubagentGroup | None = None
109
+ self.compact_next_run = False
110
+ self.pending_extension_mutation: tuple[str, str | None] | None = None
111
+ self.prompt_queue: deque[str] = deque()
112
+ self._escape_interrupt_deadline = 0.0
113
+ self.ui_mode: Literal["welcome", "chat"] = "chat"
114
+
115
+ def _display_model(self) -> str:
116
+ if self.model:
117
+ provider = self.config.providers.get(self.model)
118
+ return f"{self.model}/{provider.model}" if provider is not None else self.model
119
+ if self.config.primary_provider:
120
+ provider = self.config.providers.get(self.config.primary_provider)
121
+ if provider is not None:
122
+ return provider.model
123
+ return "按配置"
124
+
125
+ def _make_banner(self) -> RichText:
126
+ banner = RichText()
127
+ banner.append("▣ ", style="bold color(75)")
128
+ banner.append("windcode", style="bold color(252)")
129
+ banner.append(" · ", style="color(240)")
130
+ banner.append(self._display_model(), style="color(246)")
131
+ banner.append(" · ", style="color(240)")
132
+ banner.append(str(self.workspace), style="color(242)")
133
+ return banner
134
+
135
+ def compose(self) -> ComposeResult:
136
+ yield Static(self._make_banner(), id="title-bar")
137
+ with Vertical(id="content-shell"):
138
+ yield WelcomeView(
139
+ model=self._display_model(),
140
+ permission=self.permission_mode,
141
+ sandbox=self.config.sandbox.enabled,
142
+ workspace=self.workspace,
143
+ id="welcome-view",
144
+ )
145
+ yield MessageStream(id="chat-area")
146
+ with Vertical(id="input-dock"):
147
+ with Vertical(id="input-area"):
148
+ yield CommandMenu(id="command-menu")
149
+ yield ChatInput(
150
+ placeholder="输入任务, 或输入 / 使用命令",
151
+ id="chat-input",
152
+ )
153
+ yield StatusBar(id="status-bar")
154
+
155
+ async def on_mount(self) -> None:
156
+ await self.client.__aenter__()
157
+ self.register_theme(
158
+ Theme(
159
+ name="windcode",
160
+ primary="#5fa8e8",
161
+ secondary="#d9a557",
162
+ warning="#d9a557",
163
+ error="#e36d6d",
164
+ background="#151719",
165
+ surface="#1b2024",
166
+ panel="#20262b",
167
+ dark=True,
168
+ )
169
+ )
170
+ self.theme = "windcode"
171
+ self._set_ui_mode("chat" if self.session_id else "welcome")
172
+ self.set_class(self.size.width < 60, "narrow")
173
+ self.query_one("#chat-input", ChatInput).focus()
174
+ self._update_status("loading MCP" if self.client.required_mcp_loading else "idle")
175
+ if self.client.required_mcp_loading:
176
+ self.query_one("#welcome-view", WelcomeView).start_mcp_loading()
177
+ self.run_worker(self._load_required_mcp(), group="mcp-startup", exclusive=True)
178
+ if self.session_id is not None and self.client.session_exists(self.session_id):
179
+ await self._restore_session(self.session_id, announce=False)
180
+
181
+ async def _load_required_mcp(self) -> None:
182
+ try:
183
+ await self.client.wait_for_required_mcp()
184
+ except Exception as exc:
185
+ self.query_one("#welcome-view", WelcomeView).stop_mcp_loading()
186
+ self._update_status("MCP 加载失败")
187
+ await self._show_system_message(f"MCP 服务加载失败: {exc}", error=True)
188
+ return
189
+ self.query_one("#welcome-view", WelcomeView).stop_mcp_loading()
190
+ self._update_status("idle")
191
+ await self._show_system_message("MCP 服务已加载")
192
+
193
+ def on_resize(self, event: events.Resize) -> None:
194
+ self.set_class(event.size.width < 60, "narrow")
195
+
196
+ async def on_unmount(self) -> None:
197
+ self.prompt_queue.clear()
198
+ if self.handle is not None and not self.handle.done:
199
+ await self.handle.cancel()
200
+ await self.client.aclose()
201
+
202
+ def _update_status(self, state: str) -> None:
203
+ self.query_one("#status-bar", StatusBar).set_state(
204
+ model=self._display_model(),
205
+ permission=self.permission_mode,
206
+ sandbox=self.config.sandbox.enabled,
207
+ state=state,
208
+ delegation=self.config.subagents.mode.value,
209
+ )
210
+ self.query_one("#welcome-view", WelcomeView).set_context(
211
+ model=self._display_model(),
212
+ permission=self.permission_mode,
213
+ sandbox=self.config.sandbox.enabled,
214
+ workspace=self.workspace,
215
+ )
216
+
217
+ async def action_cycle_permission_mode(self) -> None:
218
+ modes = tuple(PermissionMode)
219
+ current = PermissionMode(self.permission_mode)
220
+ self.permission_mode = modes[(modes.index(current) + 1) % len(modes)].value
221
+ active = self.handle is not None and not self.handle.done
222
+ if active:
223
+ assert self.handle is not None
224
+ self.handle.set_permission_mode(self.permission_mode)
225
+ self._update_status("running" if active else "idle")
226
+
227
+ async def action_copy_last_response(self) -> None:
228
+ text = self.query_one("#chat-area", MessageStream).last_ai_text
229
+ if not text:
230
+ await self._show_system_message("没有可复制的内容", error=True)
231
+ return
232
+ try:
233
+ await self.copy_to_clipboard(text)
234
+ preview = text.replace("\n", " ")[:60]
235
+ await self._show_system_message(
236
+ f"已复制到剪贴板: {preview}{'...' if len(text) > 60 else ''}"
237
+ )
238
+ except Exception:
239
+ await self._show_system_message(
240
+ "复制失败; 终端可能不支持 OSC 52 剪贴板, 试试 Shift+鼠标选择", error=True
241
+ )
242
+
243
+ def _set_ui_mode(self, mode: Literal["welcome", "chat"]) -> None:
244
+ self.ui_mode = mode
245
+ welcome = self.query_one("#welcome-view", WelcomeView)
246
+ welcome.set_context(
247
+ model=self._display_model(),
248
+ permission=self.permission_mode,
249
+ sandbox=self.config.sandbox.enabled,
250
+ workspace=self.workspace,
251
+ )
252
+ welcome.display = mode == "welcome"
253
+ self.query_one("#title-bar", Static).display = mode == "chat"
254
+ self.query_one("#chat-area", MessageStream).display = mode == "chat"
255
+ self.set_class(mode == "welcome", "welcome-mode")
256
+
257
+ def _hide_command_menu(self) -> None:
258
+ self.query_one("#command-menu", CommandMenu).hide()
259
+
260
+ async def _show_system_message(self, text: str, *, error: bool = False) -> None:
261
+ if self.ui_mode == "welcome":
262
+ self.query_one("#welcome-view", WelcomeView).show_notice(text, error=error)
263
+ return
264
+ await self.query_one("#chat-area", MessageStream).add_system_message(text, error=error)
265
+
266
+ def _resolve_session_id(self, value: str) -> str:
267
+ session_ids = tuple(session.session_id for session in self.client.list_sessions())
268
+ if value in session_ids:
269
+ return value
270
+ matches = tuple(session_id for session_id in session_ids if session_id.startswith(value))
271
+ if len(matches) == 1:
272
+ return matches[0]
273
+ if not matches:
274
+ raise ValueError(f"未知会话: {value}")
275
+ raise ValueError(f"会话短 ID 不唯一: {value}")
276
+
277
+ def _resolve_record_id(self, value: str) -> str:
278
+ if self.session_id is None:
279
+ raise ValueError("回退前请先选择会话")
280
+ record_ids = tuple(
281
+ record.record_id for record in self.client.load_session_records(self.session_id)
282
+ )
283
+ if value in record_ids:
284
+ return value
285
+ matches = tuple(record_id for record_id in record_ids if record_id.startswith(value))
286
+ if len(matches) == 1:
287
+ return matches[0]
288
+ if not matches:
289
+ raise ValueError(f"未知记录: {value}")
290
+ raise ValueError(f"记录短 ID 不唯一: {value}")
291
+
292
+ async def _restore_session(self, session_id: str, *, announce: bool = True) -> None:
293
+ messages = self.query_one("#chat-area", MessageStream)
294
+ if self.handle is not None and self.handle.done:
295
+ self.handle = None
296
+ self.session_id = session_id
297
+ self.compact_next_run = False
298
+ self.subagent_group = None
299
+ self._set_ui_mode("chat")
300
+ await messages.load_history(self.client.load_session_messages(session_id))
301
+ if announce:
302
+ metadata = next(
303
+ session
304
+ for session in self.client.list_sessions()
305
+ if session.session_id == session_id
306
+ )
307
+ await messages.add_system_message(f"已恢复会话: {metadata.summary or session_id}")
308
+ self.query_one("#chat-input", ChatInput).focus()
309
+ self._update_status("idle")
310
+
311
+ def _model_config(
312
+ self,
313
+ providers: dict[str, ProviderConfig],
314
+ *,
315
+ primary: str | None,
316
+ fallback: tuple[str, ...],
317
+ ) -> AppConfig:
318
+ data = self.config.model_dump(mode="python")
319
+ data.update(
320
+ providers=providers,
321
+ primary_provider=primary,
322
+ fallback_chain=fallback,
323
+ )
324
+ return AppConfig.model_validate(data)
325
+
326
+ async def _apply_model_config(self, config: AppConfig) -> bool:
327
+ try:
328
+ await self.client.reconfigure_models(config, config_file=self.config_file)
329
+ except (OSError, RuntimeError, ValueError) as exc:
330
+ if self.provider_manager is not None:
331
+ self.provider_manager.show_error(str(exc))
332
+ elif self.model_manager is not None:
333
+ self.model_manager.show_error(str(exc))
334
+ else:
335
+ await self._show_system_message(str(exc), error=True)
336
+ return False
337
+ self.config = config
338
+ return True
339
+
340
+ def _connected_providers(self) -> dict[str, bool]:
341
+ connected: dict[str, bool] = {}
342
+ for alias, provider in self.config.providers.items():
343
+ available = bool(provider.api_key_env and os.environ.get(provider.api_key_env))
344
+ if not available and provider.credential_id:
345
+ try:
346
+ available = bool(self.client.credential_store.get(provider.credential_id))
347
+ except CredentialStoreError:
348
+ available = False
349
+ connected[alias] = available
350
+ return connected
351
+
352
+ async def _open_model_manager(self, *, selected: str | None = None) -> None:
353
+ if self.session_selector is not None:
354
+ await self.session_selector.remove()
355
+ self.session_selector = None
356
+ if self.model_manager is not None:
357
+ await self.model_manager.dismiss()
358
+ self.model_manager = ModelManager(
359
+ self.config.providers,
360
+ selected=selected or self.model,
361
+ primary=self.config.primary_provider,
362
+ connected=self._connected_providers(),
363
+ )
364
+ await self.push_screen(self.model_manager)
365
+
366
+ async def _close_model_manager(self) -> None:
367
+ if self.model_manager is not None:
368
+ await self.model_manager.dismiss()
369
+ self.model_manager = None
370
+ self.query_one("#chat-input", ChatInput).focus()
371
+
372
+ async def _open_provider_manager(
373
+ self,
374
+ *,
375
+ selected: str | None = None,
376
+ preset_id: str | None = None,
377
+ ) -> None:
378
+ await self._close_model_manager()
379
+ if self.provider_manager is not None:
380
+ await self.provider_manager.dismiss()
381
+ self.provider_manager = ProviderManager(
382
+ self.config.providers,
383
+ selected=selected or self.model,
384
+ primary=self.config.primary_provider,
385
+ connected=self._connected_providers(),
386
+ preset_id=preset_id,
387
+ )
388
+ await self.push_screen(self.provider_manager)
389
+
390
+ async def _close_provider_manager(self) -> None:
391
+ if self.provider_manager is not None:
392
+ await self.provider_manager.dismiss()
393
+ self.provider_manager = None
394
+ self.query_one("#chat-input", ChatInput).focus()
395
+
396
+ async def _open_memory_manager(self) -> None:
397
+ if self.memory_manager is not None:
398
+ await self.memory_manager.dismiss()
399
+ records = self.client.list_memories() if self.config.memory.enabled else ()
400
+ self.memory_manager = MemoryManager(records, enabled=self.config.memory.enabled)
401
+ await self.push_screen(self.memory_manager)
402
+
403
+ async def _close_memory_manager(self) -> None:
404
+ if self.memory_manager is not None:
405
+ await self.memory_manager.dismiss()
406
+ self.memory_manager = None
407
+ self.query_one("#chat-input", ChatInput).focus()
408
+
409
+ async def _select_model(self, alias: str) -> None:
410
+ if alias not in self.client.transport_registry.aliases:
411
+ raise ValueError(f"未配置模型: {alias}")
412
+ self.model = alias
413
+ self.query_one("#title-bar", Static).update(self._make_banner())
414
+ self._update_status("idle")
415
+ await self._close_model_manager()
416
+ await self._show_system_message(f"模型: {self._display_model()}")
417
+
418
+ @on(ChatInput.SlashMenuUpdate)
419
+ def update_slash_menu(self, event: ChatInput.SlashMenuUpdate) -> None:
420
+ menu = self.query_one("#command-menu", CommandMenu)
421
+ matches = (
422
+ complete_commands(event.prefix, self._extension_command_definitions())
423
+ if event.prefix is not None
424
+ else ()
425
+ )
426
+ menu.show_commands(matches) if matches else menu.hide()
427
+
428
+ @on(ChatInput.SkillMenuUpdate)
429
+ def update_skill_menu(self, event: ChatInput.SkillMenuUpdate) -> None:
430
+ menu = self.query_one("#command-menu", CommandMenu)
431
+ skills = tuple(
432
+ SkillDefinition(item.name, item.description) for item in self.client.search_skills()
433
+ )
434
+ matches = complete_skills(event.prefix, skills) if event.prefix is not None else ()
435
+ menu.show_commands(matches) if matches else menu.hide()
436
+
437
+ def _extension_command_definitions(self) -> tuple[CommandDefinition, ...]:
438
+ return tuple(
439
+ CommandDefinition(route.name, f"插件命令 · {route.source_id}")
440
+ for route in self.client.extension_commands(reserved=COMMANDS)
441
+ )
442
+
443
+ @on(ChatInput.Submitted)
444
+ async def submit_prompt(self, event: ChatInput.Submitted) -> None:
445
+ value = event.text.strip()
446
+ self._hide_command_menu()
447
+ if not value:
448
+ return
449
+ if value.startswith("/"):
450
+ try:
451
+ definitions = self._extension_command_definitions()
452
+ await self._command(
453
+ parse_command(value, frozenset(item.name for item in definitions))
454
+ )
455
+ except ValueError as exc:
456
+ await self._show_system_message(str(exc), error=True)
457
+ return
458
+ await self._start_prompt(value)
459
+
460
+ async def _start_prompt(self, value: str) -> None:
461
+ if self.handle is not None and not self.handle.done:
462
+ self.prompt_queue.append(value)
463
+ self._update_status(f"运行中 · 队列 {len(self.prompt_queue)}")
464
+ return
465
+ await self._launch_prompt(value)
466
+
467
+ async def _launch_prompt(self, value: str) -> None:
468
+ messages = self.query_one("#chat-area", MessageStream)
469
+ self._set_ui_mode("chat")
470
+ await messages.add_user_message(value)
471
+ await messages.begin_run()
472
+ request = RunRequest(
473
+ prompt=value,
474
+ workspace=self.workspace,
475
+ session_id=self.session_id,
476
+ model=self.model,
477
+ permission_mode=self.permission_mode,
478
+ compact_before_run=self.compact_next_run,
479
+ )
480
+ self.handle = self.client.start_run(request)
481
+ self.compact_next_run = False
482
+ self._update_status("running")
483
+ self.run_worker(self._consume(self.handle), group="run", exclusive=True)
484
+
485
+ async def _consume(self, handle: RunHandle) -> None:
486
+ messages = self.query_one("#chat-area", MessageStream)
487
+ try:
488
+ async for event in handle:
489
+ self.session_id = event.session_id
490
+ await messages.apply_event(event)
491
+ if isinstance(event, ToolStarted):
492
+ block = ToolBlock(event)
493
+ self.tool_blocks[event.call_id] = block
494
+ await messages.mount_in_ai_row(block)
495
+ elif isinstance(event, ToolProgress):
496
+ block = self.tool_blocks.get(event.call_id)
497
+ if block is not None:
498
+ block.progress(event)
499
+ elif isinstance(event, ToolFinished):
500
+ block = self.tool_blocks.get(event.call_id)
501
+ if block is not None:
502
+ block.finish(event)
503
+ elif isinstance(event, ApprovalRequested):
504
+ messages.pause_thinking(event.request_id)
505
+ await messages.begin_block()
506
+ widget = ApprovalWidget(event)
507
+ self.approval_widgets[event.request_id] = widget
508
+ await messages.mount(widget)
509
+ elif isinstance(event, UserInputRequested):
510
+ await messages.begin_block()
511
+ await messages.mount(QuestionWidget(event))
512
+ elif isinstance(event, SubagentEvent):
513
+ if self.subagent_group is None or not self.subagent_group.is_attached:
514
+ await messages.begin_block()
515
+ self.subagent_group = SubagentGroup()
516
+ await messages.mount_in_ai_row(self.subagent_group)
517
+ await self.subagent_group.apply_event(event)
518
+ messages.scroll_end(animate=False)
519
+ result = await handle.result()
520
+ self._escape_interrupt_deadline = 0.0
521
+ self._update_status(result.status)
522
+ finally:
523
+ try:
524
+ self.query_one("#chat-input", ChatInput).focus()
525
+ except NoMatches:
526
+ pass
527
+ if self.prompt_queue:
528
+ value = self.prompt_queue.popleft()
529
+ await self._launch_prompt(value)
530
+
531
+ @on(ChatInput.EscapePressed)
532
+ async def escape_pressed(self) -> None:
533
+ if self.handle is None or self.handle.done:
534
+ self._escape_interrupt_deadline = 0.0
535
+ return
536
+ now = monotonic()
537
+ if now <= self._escape_interrupt_deadline:
538
+ self._escape_interrupt_deadline = 0.0
539
+ await self.handle.cancel()
540
+ return
541
+ self._escape_interrupt_deadline = now + self.ESCAPE_INTERRUPT_WINDOW
542
+ self._update_status("再次 Esc 中断")
543
+ self.set_timer(self.ESCAPE_INTERRUPT_WINDOW, self._reset_escape_interrupt)
544
+
545
+ def _reset_escape_interrupt(self) -> None:
546
+ if not self._escape_interrupt_deadline or monotonic() < self._escape_interrupt_deadline:
547
+ return
548
+ self._escape_interrupt_deadline = 0.0
549
+ if self.handle is not None and not self.handle.done:
550
+ state = f"运行中 · 队列 {len(self.prompt_queue)}" if self.prompt_queue else "running"
551
+ self._update_status(state)
552
+
553
+ @on(ApprovalWidget.Decision)
554
+ async def approval_decision(self, event: ApprovalWidget.Decision) -> None:
555
+ self.query_one("#chat-area", MessageStream).resume_thinking(event.request_id)
556
+ widget = self.approval_widgets.pop(event.request_id, None)
557
+ if widget is not None and widget.is_attached:
558
+ await widget.remove()
559
+ if self.handle is not None:
560
+ await self.handle.respond(ApprovalResponse(event.request_id, event.decision))
561
+
562
+ @on(QuestionWidget.Submitted)
563
+ async def question_submitted(self, event: QuestionWidget.Submitted) -> None:
564
+ if self.handle is not None:
565
+ await self.handle.respond(UserResponse(event.request_id, event.answers))
566
+
567
+ @on(SessionSelector.Selected)
568
+ async def session_selected(self, event: SessionSelector.Selected) -> None:
569
+ if self.handle is None or self.handle.done:
570
+ if self.session_selector is not None:
571
+ await self.session_selector.remove()
572
+ self.session_selector = None
573
+ await self._restore_session(event.session_id)
574
+
575
+ @on(SessionSelector.Cancelled)
576
+ async def session_cancelled(self) -> None:
577
+ if self.session_selector is not None:
578
+ await self.session_selector.remove()
579
+ self.session_selector = None
580
+ self.query_one("#chat-input", ChatInput).focus()
581
+
582
+ @on(ModelManager.Use)
583
+ async def model_use(self, event: ModelManager.Use) -> None:
584
+ await self._select_model(event.alias)
585
+
586
+ @on(ModelManager.Manage)
587
+ async def model_manage(self) -> None:
588
+ await self._open_provider_manager()
589
+
590
+ @on(ModelManager.Connect)
591
+ async def model_connect(self, event: ModelManager.Connect) -> None:
592
+ await self._open_provider_manager(
593
+ selected=event.alias,
594
+ preset_id=None if event.alias is not None else event.provider_id,
595
+ )
596
+
597
+ @on(ProviderManager.Save)
598
+ async def provider_save(self, event: ProviderManager.Save) -> None:
599
+ previous_secret: str | None = None
600
+ if event.secret is not None and event.provider.credential_id is not None:
601
+ try:
602
+ previous_secret = self.client.credential_store.get(event.provider.credential_id)
603
+ self.client.credential_store.set(event.provider.credential_id, event.secret)
604
+ except CredentialStoreError as exc:
605
+ if self.provider_manager is not None:
606
+ self.provider_manager.show_error(str(exc))
607
+ return
608
+ providers = dict(self.config.providers)
609
+ providers[event.alias] = event.provider
610
+ primary = self.config.primary_provider or event.alias
611
+ config = self._model_config(
612
+ providers,
613
+ primary=primary,
614
+ fallback=self.config.fallback_chain,
615
+ )
616
+ if await self._apply_model_config(config):
617
+ self.model = event.alias
618
+ self.query_one("#title-bar", Static).update(self._make_banner())
619
+ self._update_status("idle")
620
+ await self._open_provider_manager(selected=event.alias)
621
+ elif event.secret is not None and event.provider.credential_id is not None:
622
+ if previous_secret is None:
623
+ self.client.credential_store.delete(event.provider.credential_id)
624
+ else:
625
+ self.client.credential_store.set(event.provider.credential_id, previous_secret)
626
+
627
+ @on(ProviderManager.Delete)
628
+ async def provider_delete(self, event: ProviderManager.Delete) -> None:
629
+ provider = self.config.providers.get(event.alias)
630
+ providers = dict(self.config.providers)
631
+ providers.pop(event.alias, None)
632
+ primary = self.config.primary_provider
633
+ if primary == event.alias:
634
+ primary = next(iter(providers), None)
635
+ fallback = tuple(
636
+ alias
637
+ for alias in self.config.fallback_chain
638
+ if alias != event.alias and alias != primary and alias in providers
639
+ )
640
+ config = self._model_config(providers, primary=primary, fallback=fallback)
641
+ if await self._apply_model_config(config):
642
+ if self.model == event.alias:
643
+ self.model = primary
644
+ self.query_one("#title-bar", Static).update(self._make_banner())
645
+ self._update_status("idle")
646
+ if provider is not None and provider.credential_id is not None:
647
+ try:
648
+ self.client.credential_store.delete(provider.credential_id)
649
+ except CredentialStoreError as exc:
650
+ await self._show_system_message(str(exc), error=True)
651
+ await self._open_provider_manager(selected=self.model)
652
+
653
+ @on(ProviderManager.SetDefault)
654
+ async def provider_set_default(self, event: ProviderManager.SetDefault) -> None:
655
+ ordered = (
656
+ self.config.primary_provider,
657
+ *self.config.fallback_chain,
658
+ )
659
+ fallback = tuple(
660
+ alias
661
+ for alias in ordered
662
+ if alias is not None and alias != event.alias and alias in self.config.providers
663
+ )
664
+ config = self._model_config(
665
+ dict(self.config.providers),
666
+ primary=event.alias,
667
+ fallback=fallback,
668
+ )
669
+ if await self._apply_model_config(config):
670
+ self.model = event.alias
671
+ self.query_one("#title-bar", Static).update(self._make_banner())
672
+ self._update_status("idle")
673
+ await self._open_provider_manager(selected=event.alias)
674
+
675
+ @on(ModelManager.Closed)
676
+ async def model_manager_closed(self) -> None:
677
+ await self._close_model_manager()
678
+
679
+ @on(ProviderManager.Closed)
680
+ async def provider_manager_closed(self) -> None:
681
+ await self._close_provider_manager()
682
+ await self._open_model_manager()
683
+
684
+ @on(MemoryManager.EnabledChanged)
685
+ async def memory_enabled_changed(self, event: MemoryManager.EnabledChanged) -> None:
686
+ try:
687
+ self.client.set_memory_enabled(event.enabled, config_file=self.config_file)
688
+ except (OSError, ValueError) as exc:
689
+ await self._show_system_message(f"无法保存长期记忆设置: {exc}", error=True)
690
+ return
691
+ self.config = self.client.config
692
+ if self.memory_manager is not None:
693
+ records = self.client.list_memories() if event.enabled else ()
694
+ self.memory_manager.refresh_records(records)
695
+
696
+ @on(MemoryManager.Forget)
697
+ async def memory_forget(self, event: MemoryManager.Forget) -> None:
698
+ self.client.delete_memory(event.memory_id)
699
+ if self.memory_manager is not None:
700
+ self.memory_manager.refresh_records(self.client.list_memories())
701
+
702
+ @on(MemoryManager.ActivationChanged)
703
+ async def memory_activation_changed(self, event: MemoryManager.ActivationChanged) -> None:
704
+ self.client.set_memory_activation(event.memory_id, event.activation)
705
+ if self.memory_manager is not None:
706
+ self.memory_manager.refresh_records(self.client.list_memories())
707
+
708
+ @on(MemoryManager.Rebuild)
709
+ async def memory_rebuild(self) -> None:
710
+ self.client.rebuild_memory_index()
711
+ if self.memory_manager is not None:
712
+ self.memory_manager.refresh_records(self.client.list_memories())
713
+
714
+ @on(MemoryManager.Closed)
715
+ async def memory_manager_closed(self) -> None:
716
+ await self._close_memory_manager()
717
+
718
+ async def _command(self, command: SlashCommand) -> None:
719
+ messages = self.query_one("#chat-area", MessageStream)
720
+ active = self.handle is not None and not self.handle.done
721
+ routes = {route.name: route for route in self.client.extension_commands(reserved=COMMANDS)}
722
+ route = routes.get(command.name)
723
+ if route is not None:
724
+ target_kind, selector_value = route.target.split(":", 1)
725
+ selector = {
726
+ "skill": f"${selector_value}",
727
+ "prompt": f"@prompt:{selector_value}",
728
+ "capability": f"@capability:{selector_value}",
729
+ }[target_kind]
730
+ prompt = " ".join((selector, *command.arguments))
731
+ await self._start_prompt(prompt)
732
+ return
733
+ if command.name == "quit":
734
+ self.exit()
735
+ return
736
+ if command.name == "new":
737
+ if active:
738
+ raise ValueError("任务运行期间不能新建会话")
739
+ self.session_id = None
740
+ self.compact_next_run = False
741
+ await messages.clear()
742
+ self.subagent_group = None
743
+ self._set_ui_mode("welcome")
744
+ self.query_one("#welcome-view", WelcomeView).clear_notice()
745
+ self.query_one("#chat-input", ChatInput).focus()
746
+ elif command.name == "resume":
747
+ if active:
748
+ raise ValueError("任务运行期间不能恢复会话")
749
+ if not command.arguments:
750
+ if self.session_selector is not None:
751
+ await self.session_selector.remove()
752
+ self.session_selector = SessionSelector(self.client.list_sessions())
753
+ await self.query_one("#input-area", Vertical).mount(
754
+ self.session_selector, before="#chat-input"
755
+ )
756
+ self.session_selector.focus()
757
+ await self._show_system_message("请选择要恢复的会话")
758
+ return
759
+ if len(command.arguments) != 1:
760
+ raise ValueError("用法: /resume 会话ID")
761
+ session_id = self._resolve_session_id(command.arguments[0])
762
+ await self._restore_session(session_id)
763
+ elif command.name == "history":
764
+ if command.arguments:
765
+ raise ValueError("用法: /history")
766
+ if self.session_id is None:
767
+ raise ValueError("请先选择会话")
768
+ lines = ["当前会话历史节点:"]
769
+ for record in self.client.load_session_records(self.session_id):
770
+ if record.record_type != "conversation_message":
771
+ continue
772
+ message = message_from_dict(record.payload)
773
+ text = " ".join(
774
+ block.text for block in message.content if isinstance(block, TextBlock)
775
+ )
776
+ preview = " ".join(text.split())[:48]
777
+ lines.append(
778
+ f"{record.sequence}. {record.record_id[:12]} {message.role.value} {preview}"
779
+ )
780
+ await self._show_system_message("\n".join(lines))
781
+ elif command.name == "rewind":
782
+ if active:
783
+ raise ValueError("任务运行期间不能回退会话")
784
+ if len(command.arguments) != 1:
785
+ raise ValueError("用法: /rewind 记录ID")
786
+ record_id = self._resolve_record_id(command.arguments[0])
787
+ assert self.session_id is not None
788
+ self.client.rewind_session(self.session_id, record_id)
789
+ await self._restore_session(self.session_id, announce=False)
790
+ await self._show_system_message(f"已回退到记录 {record_id[:12]}")
791
+ elif command.name == "mode":
792
+ if len(command.arguments) != 1:
793
+ raise ValueError("用法: /mode 模式")
794
+ try:
795
+ self.permission_mode = PermissionMode(command.arguments[0]).value
796
+ except ValueError as exc:
797
+ raise ValueError(f"未知权限模式: {command.arguments[0]}") from exc
798
+ if active:
799
+ assert self.handle is not None
800
+ self.handle.set_permission_mode(self.permission_mode)
801
+ self._update_status("running" if active else "idle")
802
+ elif command.name == "model":
803
+ if active:
804
+ raise ValueError("任务运行期间不能切换模型")
805
+ if not command.arguments:
806
+ await self._open_model_manager()
807
+ return
808
+ if len(command.arguments) != 1:
809
+ raise ValueError("用法: /model [配置别名]")
810
+ await self._select_model(command.arguments[0])
811
+ elif command.name == "compact":
812
+ if command.arguments:
813
+ raise ValueError("用法: /compact")
814
+ if active and self.handle is not None:
815
+ await self.handle.compact()
816
+ await self._show_system_message("已请求压缩上下文")
817
+ else:
818
+ if self.session_id is None:
819
+ raise ValueError("压缩前请先选择会话")
820
+ self.compact_next_run = True
821
+ await self._show_system_message("将在下一轮模型请求前压缩上下文")
822
+ elif command.name == "clear":
823
+ if command.arguments:
824
+ raise ValueError("用法: /clear")
825
+ await messages.clear()
826
+ self.subagent_group = None
827
+ elif command.name == "help":
828
+ if command.arguments:
829
+ raise ValueError("用法: /help")
830
+ lines = ["可用命令:"]
831
+ for definition in complete_commands("/", self._extension_command_definitions()):
832
+ hint = f" {definition.argument_hint}" if definition.argument_hint else ""
833
+ lines.append(f"/{definition.name}{hint} {definition.description}")
834
+ await self._show_system_message("\n".join(lines))
835
+ elif command.name == "status":
836
+ await self._show_system_message(
837
+ f"会话: {self.session_id or '新会话'} 模型: {self._display_model()} "
838
+ f"权限: {self.permission_mode} 委派: {self.config.subagents.mode.value}"
839
+ )
840
+ elif command.name == "agents":
841
+ if command.arguments:
842
+ raise ValueError("用法: /agents")
843
+ records = () if self.handle is None else self.handle.subagents()
844
+ if not records:
845
+ await self._show_system_message("当前没有子智能体任务")
846
+ else:
847
+ lines = ["子智能体任务:"]
848
+ for record in records:
849
+ line = (
850
+ f"{record.task_index + 1}. {record.spec.task_name} · "
851
+ f"{record.spec.role.value} · {record.status.value}"
852
+ )
853
+ if record.worktree_path is not None:
854
+ line += f" · Worktree: {record.worktree_path}"
855
+ lines.append(line)
856
+ await self._show_system_message("\n".join(lines))
857
+ elif command.name == "memory":
858
+ if active:
859
+ raise ValueError("任务运行期间不能管理长期记忆")
860
+ if not command.arguments:
861
+ await self._open_memory_manager()
862
+ return
863
+ action = command.arguments[0] if command.arguments else "status"
864
+ arguments = command.arguments[1:]
865
+ if not self.config.memory.enabled:
866
+ if action == "status":
867
+ await self._show_system_message("长期记忆: 已禁用")
868
+ return
869
+ raise ValueError("长期记忆已在配置中禁用")
870
+ if action == "status":
871
+ records = self.client.list_memories()
872
+ candidates = sum(item.status is MemoryStatus.CANDIDATE for item in records)
873
+ active_count = sum(item.status is MemoryStatus.ACTIVE for item in records)
874
+ await self._show_system_message(
875
+ f"长期记忆: 已启用; 生效 {active_count}; 候选 {candidates}; 总计 {len(records)}"
876
+ )
877
+ elif action == "candidates":
878
+ records = self.client.list_memories(status=MemoryStatus.CANDIDATE)
879
+ text = "\n".join(f"{item.memory_id[:10]} {item.title}" for item in records)
880
+ await self._show_system_message(text or "没有待确认的记忆候选")
881
+ elif action == "search":
882
+ if not arguments:
883
+ raise ValueError("用法: /memory search 关键词")
884
+ records = self.client.search_memories(" ".join(arguments))
885
+ text = "\n".join(
886
+ f"{item.memory_id[:10]} [{item.status.value}] {item.title}" for item in records
887
+ )
888
+ await self._show_system_message(text or "没有匹配的记忆")
889
+ elif action == "show":
890
+ if len(arguments) != 1:
891
+ raise ValueError("用法: /memory show ID")
892
+ matches = tuple(
893
+ item
894
+ for item in self.client.list_memories()
895
+ if item.memory_id.startswith(arguments[0])
896
+ )
897
+ if len(matches) != 1:
898
+ raise ValueError("记忆 ID 不存在或前缀不唯一")
899
+ item = matches[0]
900
+ await self._show_system_message(
901
+ f"{item.title}\n类型: {item.kind.value}; 范围: {item.scope.value}; "
902
+ f"状态: {item.status.value}; 激活: {item.activation.value}; "
903
+ f"优先级: {item.priority}\n摘要: {item.summary}\n\n{item.body}"
904
+ )
905
+ elif action == "activation":
906
+ if len(arguments) != 2:
907
+ raise ValueError("用法: /memory activation ID <always|search|manual>")
908
+ matches = tuple(
909
+ item
910
+ for item in self.client.list_memories()
911
+ if item.memory_id.startswith(arguments[0])
912
+ )
913
+ if len(matches) != 1:
914
+ raise ValueError("记忆 ID 不存在或前缀不唯一")
915
+ if matches[0].status is not MemoryStatus.ACTIVE:
916
+ raise ValueError("候选或非生效记忆必须先确认, 才能设置自动激活策略")
917
+ updated = self.client.set_memory_activation(matches[0].memory_id, arguments[1])
918
+ await self._show_system_message(
919
+ f"记忆激活策略已更新: {updated.title} -> {updated.activation.value}"
920
+ )
921
+ elif action in {"confirm", "reject", "forget"}:
922
+ if len(arguments) != 1:
923
+ raise ValueError(f"用法: /memory {action} ID")
924
+ matches = tuple(
925
+ item
926
+ for item in self.client.list_memories()
927
+ if item.memory_id.startswith(arguments[0])
928
+ )
929
+ if len(matches) != 1:
930
+ raise ValueError("记忆 ID 不存在或前缀不唯一")
931
+ item = matches[0]
932
+ if action == "confirm":
933
+ updated = self.client.confirm_memory(item.memory_id)
934
+ await self._show_system_message(f"记忆已确认: {updated.title}")
935
+ elif action == "reject":
936
+ updated = self.client.reject_memory(item.memory_id)
937
+ await self._show_system_message(f"记忆已拒绝: {updated.title}")
938
+ else:
939
+ self.client.delete_memory(item.memory_id)
940
+ await self._show_system_message(f"记忆已删除: {item.title}")
941
+ elif action == "rebuild":
942
+ count = self.client.rebuild_memory_index()
943
+ await self._show_system_message(f"记忆索引已重建: {count} 条")
944
+ else:
945
+ raise ValueError(
946
+ "用法: /memory [status|candidates|search|show|activation|confirm|reject|"
947
+ "forget|rebuild]"
948
+ )
949
+ elif command.name == "extensions":
950
+ action = command.arguments[0] if command.arguments else "list"
951
+ target = command.arguments[1] if len(command.arguments) > 1 else None
952
+ if len(command.arguments) > 2:
953
+ raise ValueError("用法: /extensions [操作] [目标]")
954
+ mutations = {"install", "enable", "disable", "reload", "trust"}
955
+ if active and action in mutations:
956
+ raise ValueError("任务运行期间不能修改扩展状态")
957
+ mutation = (action, target)
958
+ if action in mutations and self.pending_extension_mutation != mutation:
959
+ self.pending_extension_mutation = mutation
960
+ target_label = "" if target is None else f" {target}"
961
+ await self._show_system_message(
962
+ f"确认扩展操作: {action}{target_label}; 再次输入相同命令执行"
963
+ )
964
+ return
965
+ if action in mutations:
966
+ self.pending_extension_mutation = None
967
+ if action == "list":
968
+ records = await self.client.list_extensions()
969
+ elif action == "inspect":
970
+ if target is None:
971
+ raise ValueError("用法: /extensions inspect 目标")
972
+ records = await self.client.inspect_extension(target)
973
+ elif action == "install":
974
+ if target is None:
975
+ raise ValueError("用法: /extensions install 路径")
976
+ result = await self.client.install_extension(
977
+ Path(target).expanduser() # noqa: ASYNC240 - local command parsing
978
+ )
979
+ await self._show_system_message(
980
+ f"已安装 {result.manifest.plugin_id}, 默认禁用; "
981
+ "运行 /extensions reload 后刷新目录"
982
+ )
983
+ records = await self.client.list_extensions()
984
+ elif action in {"enable", "disable"}:
985
+ if target is None:
986
+ raise ValueError(f"用法: /extensions {action} 目标")
987
+ await self.client.set_extension_enabled(target, action == "enable")
988
+ await self._show_system_message("扩展状态已更新; 显式 reload 后影响新运行")
989
+ records = await self.client.list_extensions()
990
+ elif action == "reload":
991
+ await self.client.reload_extensions()
992
+ records = await self.client.list_extensions()
993
+ elif action == "trust":
994
+ trust_path = (
995
+ self.workspace if target is None else Path(target).expanduser() # noqa: ASYNC240 - local command parsing
996
+ )
997
+ await self.client.trust_extension_workspace(trust_path)
998
+ await self._show_system_message("工作区信任已记录; 显式 reload 后生效")
999
+ records = await self.client.list_extensions()
1000
+ else:
1001
+ raise ValueError(f"未知扩展操作: {action}")
1002
+ await self._show_system_message(ExtensionList.render_text(records))
1003
+ self._update_status("running" if self.handle and not self.handle.done else "idle")
1004
+
1005
+ async def action_cancel_or_quit(self) -> None:
1006
+ # If the user has selected text, copy it first instead of canceling.
1007
+ selected = self.screen.get_selected_text()
1008
+ if selected:
1009
+ await self.copy_to_clipboard(selected)
1010
+ self.screen.clear_selection()
1011
+ return
1012
+ if self.handle is not None and not self.handle.done:
1013
+ await self.handle.cancel()
1014
+ return
1015
+ self.exit()