xg-cli 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 (130) hide show
  1. xg/__init__.py +3 -0
  2. xg/__main__.py +3 -0
  3. xg/adaptive/__init__.py +27 -0
  4. xg/adaptive/calibrate.py +191 -0
  5. xg/adaptive/feedback.py +170 -0
  6. xg/adaptive/learned_rules.py +284 -0
  7. xg/adaptive/signals.py +125 -0
  8. xg/adaptive/store.py +164 -0
  9. xg/agent/__init__.py +0 -0
  10. xg/agent/plan.py +631 -0
  11. xg/agent/react.py +268 -0
  12. xg/agent/team.py +1793 -0
  13. xg/assets/router.lgb +0 -0
  14. xg/assets/router_semantics.json +21278 -0
  15. xg/assets/router_semantics.onnx +0 -0
  16. xg/cli/__init__.py +0 -0
  17. xg/cli/app.py +1372 -0
  18. xg/cli/commands.py +921 -0
  19. xg/cli/completion.py +740 -0
  20. xg/cli/help.py +185 -0
  21. xg/cli/train.py +200 -0
  22. xg/config/__init__.py +0 -0
  23. xg/config/env_writer.py +121 -0
  24. xg/config/manager.py +446 -0
  25. xg/config/mcp.py +229 -0
  26. xg/config/provider_service.py +267 -0
  27. xg/config/providers.py +46 -0
  28. xg/config/settings.py +258 -0
  29. xg/config/skills.py +100 -0
  30. xg/config/smart_router_service.py +121 -0
  31. xg/config/web.py +140 -0
  32. xg/input_history/__init__.py +7 -0
  33. xg/input_history/models.py +28 -0
  34. xg/input_history/persistence.py +126 -0
  35. xg/input_history/policy.py +39 -0
  36. xg/input_history/prompt_toolkit.py +36 -0
  37. xg/input_history/store.py +118 -0
  38. xg/llm/__init__.py +0 -0
  39. xg/llm/client.py +49 -0
  40. xg/llm/factory.py +40 -0
  41. xg/llm/openai_compat.py +275 -0
  42. xg/llm/types.py +98 -0
  43. xg/mcp/__init__.py +4 -0
  44. xg/mcp/http.py +192 -0
  45. xg/mcp/manager.py +726 -0
  46. xg/mcp/models.py +86 -0
  47. xg/mcp/protocol.py +62 -0
  48. xg/mcp/resources.py +72 -0
  49. xg/mcp/schema.py +137 -0
  50. xg/mcp/stdio.py +210 -0
  51. xg/mcp/transport.py +66 -0
  52. xg/memory/__init__.py +15 -0
  53. xg/memory/context.py +327 -0
  54. xg/memory/manager.py +111 -0
  55. xg/memory/models.py +41 -0
  56. xg/memory/project.py +187 -0
  57. xg/memory/store.py +144 -0
  58. xg/router/__init__.py +124 -0
  59. xg/router/features.py +66 -0
  60. xg/router/keywords.py +50 -0
  61. xg/router/ml_router.py +178 -0
  62. xg/router/model_tiers.py +73 -0
  63. xg/router/postprocess.py +167 -0
  64. xg/router/rule_router.py +77 -0
  65. xg/router/semantic.py +138 -0
  66. xg/safety/__init__.py +0 -0
  67. xg/safety/audit.py +96 -0
  68. xg/safety/guards.py +106 -0
  69. xg/safety/hitl.py +73 -0
  70. xg/skill/__init__.py +9 -0
  71. xg/skill/errors.py +45 -0
  72. xg/skill/loader.py +42 -0
  73. xg/skill/models.py +57 -0
  74. xg/skill/parser.py +93 -0
  75. xg/skill/policy.py +40 -0
  76. xg/skill/prompt.py +45 -0
  77. xg/skill/registry.py +169 -0
  78. xg/tool/__init__.py +0 -0
  79. xg/tool/builtin.py +356 -0
  80. xg/tool/registry.py +228 -0
  81. xg/tui/__init__.py +34 -0
  82. xg/tui/app.py +612 -0
  83. xg/tui/controller.py +1296 -0
  84. xg/tui/diagrams/__init__.py +22 -0
  85. xg/tui/diagrams/layout.py +110 -0
  86. xg/tui/diagrams/markdown.py +39 -0
  87. xg/tui/diagrams/model.py +36 -0
  88. xg/tui/diagrams/parser.py +119 -0
  89. xg/tui/diagrams/renderer.py +551 -0
  90. xg/tui/i18n.py +169 -0
  91. xg/tui/messages.py +45 -0
  92. xg/tui/plan_renderables.py +147 -0
  93. xg/tui/reducer.py +1029 -0
  94. xg/tui/renderables.py +252 -0
  95. xg/tui/state.py +240 -0
  96. xg/tui/theme.tcss +208 -0
  97. xg/tui/widgets/__init__.py +1 -0
  98. xg/tui/widgets/action_card.py +94 -0
  99. xg/tui/widgets/agent_group_card.py +39 -0
  100. xg/tui/widgets/approval_modal.py +59 -0
  101. xg/tui/widgets/collapsible_card.py +40 -0
  102. xg/tui/widgets/command_suggestions.py +128 -0
  103. xg/tui/widgets/composer.py +151 -0
  104. xg/tui/widgets/config_panel.py +198 -0
  105. xg/tui/widgets/confirm_modal.py +31 -0
  106. xg/tui/widgets/footer.py +9 -0
  107. xg/tui/widgets/header.py +118 -0
  108. xg/tui/widgets/inspector.py +378 -0
  109. xg/tui/widgets/plan_modal.py +53 -0
  110. xg/tui/widgets/provider_form.py +141 -0
  111. xg/tui/widgets/queue_status.py +30 -0
  112. xg/tui/widgets/smart_router_form.py +95 -0
  113. xg/tui/widgets/transcript.py +354 -0
  114. xg/tui/workers.py +14 -0
  115. xg/web/__init__.py +21 -0
  116. xg/web/errors.py +57 -0
  117. xg/web/extract.py +127 -0
  118. xg/web/fetch.py +106 -0
  119. xg/web/markdown.py +77 -0
  120. xg/web/models.py +91 -0
  121. xg/web/providers.py +79 -0
  122. xg/web/search.py +118 -0
  123. xg/web/searxng.py +27 -0
  124. xg/web/serpapi.py +29 -0
  125. xg/web/url_policy.py +110 -0
  126. xg/web/zhipu.py +27 -0
  127. xg_cli-1.0.dist-info/METADATA +284 -0
  128. xg_cli-1.0.dist-info/RECORD +130 -0
  129. xg_cli-1.0.dist-info/WHEEL +4 -0
  130. xg_cli-1.0.dist-info/entry_points.txt +2 -0
xg/tui/app.py ADDED
@@ -0,0 +1,612 @@
1
+ """Textual fullscreen application for XG."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import time
8
+ from dataclasses import replace
9
+
10
+ from textual.app import App, ComposeResult
11
+ from textual.containers import Horizontal, Vertical
12
+ from textual.events import Resize
13
+ from textual.timer import Timer
14
+ from textual.widgets import Static
15
+
16
+ from xg.agent.plan import ReviewDecision
17
+ from xg.cli.help import parse_help_command
18
+ from xg.config.manager import ConfigManager
19
+ from xg.config.settings import Settings
20
+ from xg.input_history import HistoryConfig, InputHistory
21
+ from xg.safety.hitl import ApprovalDecision
22
+ from xg.tui.controller import SessionController
23
+ from xg.tui.messages import (
24
+ AgentGroupToggled,
25
+ CommandSuggestionSelected,
26
+ InspectorViewSelected,
27
+ StateChanged,
28
+ TraceCardToggled,
29
+ )
30
+ from xg.tui.state import TuiState
31
+ from xg.tui.widgets.action_card import InlineApprovalCard
32
+ from xg.tui.widgets.command_suggestions import CommandSuggestions
33
+ from xg.tui.widgets.composer import Composer
34
+ from xg.tui.widgets.footer import FooterBar
35
+ from xg.tui.widgets.header import HeaderBar
36
+ from xg.tui.widgets.inspector import InspectorPanel
37
+ from xg.tui.widgets.queue_status import QueueStatus
38
+ from xg.tui.widgets.transcript import TranscriptView
39
+
40
+
41
+ class XgTuiApp(App[None]):
42
+ """One active session, with all execution delegated to the controller."""
43
+
44
+ PROGRESS_TICK_SECONDS = 0.5
45
+ PROGRESS_WAITING_SECONDS = 1.0
46
+ PROGRESS_SLOW_SECONDS = 8.0
47
+
48
+ TITLE = "XG"
49
+ CSS_PATH = "theme.tcss"
50
+ BINDINGS = [
51
+ ("enter", "plan_execute", "execute plan"),
52
+ ("d", "plan_details", "plan details"),
53
+ ("shift+d", "trace_group", "trace details"),
54
+ ("r", "plan_replan", "replan"),
55
+ ("escape", "escape", "cancel or clear"),
56
+ ("ctrl+c", "cancel_turn", "取消当前任务"),
57
+ ("ctrl+l", "clear_transcript", "清屏"),
58
+ ("ctrl+r", "toggle_inspector", "侧栏"),
59
+ ("ctrl+1", "inspector_session", "Inspector Session"),
60
+ ("ctrl+2", "inspector_plan", "Inspector Plan"),
61
+ ("ctrl+3", "inspector_memory", "Inspector Memory"),
62
+ ("ctrl+4", "inspector_safety", "Inspector Safety"),
63
+ ("ctrl+tab", "inspector_next", "下一个 Inspector 视图"),
64
+ ("ctrl+shift+tab", "inspector_previous", "上一个 Inspector 视图"),
65
+ ("ctrl+t", "config_panel", "配置面板"),
66
+ ]
67
+
68
+ def __init__(self, agent, settings: Settings, manager: ConfigManager) -> None:
69
+ super().__init__()
70
+ self.agent = agent
71
+ self.settings = settings
72
+ self.input_history = getattr(agent, "input_history", None) or InputHistory(
73
+ project_root=manager.project_dir.parent,
74
+ user_dir=manager.user_dir,
75
+ config=HistoryConfig(
76
+ enabled=settings.input_history_enabled,
77
+ persist=settings.input_history_persist,
78
+ max_entries=settings.input_history_max_entries,
79
+ max_entry_chars=settings.input_history_max_chars,
80
+ max_file_bytes=settings.input_history_max_bytes,
81
+ ),
82
+ )
83
+ agent.input_history = self.input_history
84
+ self.controller = SessionController(agent, settings, manager, self._on_state_change)
85
+ self._state = self.controller.snapshot()
86
+ self._pending_render_state: TuiState | None = None
87
+ self._render_timer: Timer | None = None
88
+ self._progress_timer: Timer | None = None
89
+ self._progress_turn_id = ""
90
+ self._progress_started_at: float | None = None
91
+ self._progress_frame = 0
92
+ self._modal_kind = ""
93
+ self._replan_mode = False
94
+ # 决策输入模式:"" 等待决策 / "approval_edit" 修改参数 /
95
+ # "approval_confirm_modified" 修改后二次确认
96
+ self._decision_mode = ""
97
+ self._modified_args: dict | None = None
98
+
99
+ def compose(self) -> ComposeResult:
100
+ # The work area owns the left brand/transcript column and the right
101
+ # inspector. Footer and composer deliberately remain outside it so
102
+ # they span the full terminal width.
103
+ with Horizontal(id="shell"):
104
+ with Vertical(id="main-column"):
105
+ yield HeaderBar(id="header")
106
+ yield TranscriptView(id="transcript")
107
+ yield InspectorPanel(id="inspector")
108
+
109
+ yield FooterBar()
110
+ with Vertical(id="composer-area"):
111
+ yield Static("", id="notification")
112
+ yield QueueStatus(id="queue-status")
113
+ yield CommandSuggestions()
114
+ yield Static("输入", id="composer-label")
115
+ yield Composer()
116
+
117
+ def on_mount(self) -> None:
118
+ self._render_state(self._state)
119
+ composer = self.query_one("#composer", Composer)
120
+ composer.set_input_history(self.input_history)
121
+ suggestions = self.query_one("#command-suggestions", CommandSuggestions)
122
+ suggestions.set_dynamic_registry(self.controller.completion_registry())
123
+ composer.focus()
124
+ self.run_worker(self.controller.startup(), exclusive=False, name="mcp-startup")
125
+
126
+ def _on_state_change(self, state: TuiState) -> None:
127
+ self.post_message(StateChanged(state))
128
+
129
+ def on_state_changed(self, message: StateChanged) -> None:
130
+ self._state = message.state
131
+ self._sync_progress_timer(message.state)
132
+ self._pending_render_state = message.state
133
+ self._schedule_render()
134
+
135
+ def _progress_item(self, state: TuiState):
136
+ return next(
137
+ (item for item in reversed(state.transcript) if item.kind == "progress"),
138
+ None,
139
+ )
140
+
141
+ def _stop_progress_timer(self) -> None:
142
+ if self._progress_timer is not None:
143
+ self._progress_timer.stop()
144
+ self._progress_timer = None
145
+ self._progress_turn_id = ""
146
+ self._progress_started_at = None
147
+ self._progress_frame = 0
148
+
149
+ def _sync_progress_timer(self, state: TuiState) -> None:
150
+ progress = self._progress_item(state)
151
+ if progress is None or not self.is_attached:
152
+ self._stop_progress_timer()
153
+ return
154
+ if progress.turn_id != self._progress_turn_id:
155
+ self._progress_turn_id = progress.turn_id
156
+ self._progress_started_at = time.monotonic()
157
+ self._progress_frame = 0
158
+ if self._progress_timer is None:
159
+ self._progress_timer = self.set_interval(
160
+ self.PROGRESS_TICK_SECONDS,
161
+ self._tick_progress,
162
+ )
163
+
164
+ def _progress_display_text(self, progress) -> str:
165
+ elapsed = (
166
+ time.monotonic() - self._progress_started_at
167
+ if self._progress_started_at is not None else 0.0
168
+ )
169
+ if elapsed >= self.PROGRESS_SLOW_SECONDS:
170
+ base = {
171
+ "plan": "计划生成较慢,请稍候",
172
+ "context": "上下文处理较慢,请稍候",
173
+ "response": "AI 响应较慢,请稍候",
174
+ }[progress.progress_kind]
175
+ elif progress.progress_kind == "response" and elapsed >= self.PROGRESS_WAITING_SECONDS:
176
+ base = "等待 AI 响应"
177
+ else:
178
+ base = progress.text
179
+ return f"{base}{'.' * self._progress_frame}"
180
+
181
+ def _tick_progress(self) -> None:
182
+ if not self.is_attached:
183
+ self._stop_progress_timer()
184
+ return
185
+ progress = self._progress_item(self._state)
186
+ if progress is None:
187
+ self._stop_progress_timer()
188
+ return
189
+ self._progress_frame = (self._progress_frame + 1) % 4
190
+ text = self._progress_display_text(progress)
191
+ display_items = [
192
+ replace(item, text=text) if item.id == progress.id else item
193
+ for item in self._state.transcript
194
+ ]
195
+ display_item = next(item for item in display_items if item.id == progress.id)
196
+ transcript = self.query_one("#transcript", TranscriptView)
197
+ if not transcript.update_progress(progress.id, display_item):
198
+ # The first timer tick can race the initial state render. Fall back
199
+ # to one complete render; subsequent ticks stay local to the card.
200
+ self._render_state(replace(self._state, transcript=display_items))
201
+
202
+ def _schedule_render(self) -> None:
203
+ if self._render_timer is not None or not self.is_attached:
204
+ return
205
+ interval = 1 / max(1, self.settings.tui_refresh_fps)
206
+ self._render_timer = self.set_timer(interval, self._flush_render)
207
+
208
+ def _flush_render(self) -> None:
209
+ self._render_timer = None
210
+ state = self._pending_render_state
211
+ self._pending_render_state = None
212
+ if state is not None and self.is_attached:
213
+ self._render_state(state)
214
+ if self._pending_render_state is not None:
215
+ self._schedule_render()
216
+
217
+ def on_trace_card_toggled(self, message: TraceCardToggled) -> None:
218
+ self.controller.toggle_trace_item(message.item_id)
219
+
220
+ def on_agent_group_toggled(self, message: AgentGroupToggled) -> None:
221
+ self.controller.toggle_agent_group(message.group_id)
222
+
223
+ def on_inspector_view_selected(self, message: InspectorViewSelected) -> None:
224
+ self.controller.set_inspector_view(message.view)
225
+
226
+ def on_command_suggestion_selected(self, message: CommandSuggestionSelected) -> None:
227
+ self.complete_command_suggestion(message.candidate)
228
+
229
+ def complete_command_suggestion(self, candidate) -> None:
230
+ """Apply a layered candidate and refresh the suggestion list."""
231
+ from xg.cli.completion import apply_completion
232
+
233
+ composer = self.query_one("#composer", Composer)
234
+ value, cursor = apply_completion(composer.value, composer.cursor_position, candidate)
235
+ composer.value = value
236
+ composer.cursor_position = cursor
237
+ composer.focus()
238
+ suggestions = self.query_one("#command-suggestions", CommandSuggestions)
239
+ suggestions.update_query(value, cursor)
240
+
241
+ def _render_state(self, state: TuiState) -> None:
242
+ self.query_one("#header", HeaderBar).update_state(state)
243
+ transcript = self.query_one("#transcript", TranscriptView)
244
+ transcript.request_state(state)
245
+ self.query_one("#inspector", InspectorPanel).update_state(state)
246
+ note = self.query_one("#notification", Static)
247
+ note.update(state.notification)
248
+ note.display = bool(state.notification)
249
+ self.query_one("#queue-status", QueueStatus).update_state(state)
250
+ composer = self.query_one("#composer", Composer)
251
+ if state.pending_approval is not None:
252
+ # Cards are read-only; the decision is typed into the Composer.
253
+ # TranscriptView keeps the action card stable while its request
254
+ # state is being reconciled.
255
+ if self._decision_mode:
256
+ cards = list(self.query(InlineApprovalCard))
257
+ if cards:
258
+ cards[-1].set_mode(self._decision_mode, self._modified_args)
259
+ composer.focus()
260
+ elif state.pending_confirmation is not None:
261
+ composer.focus()
262
+ else:
263
+ self._decision_mode = ""
264
+ self._modified_args = None
265
+ transcript.set_approval_mode(
266
+ self._decision_mode if state.pending_approval is not None else "",
267
+ self._modified_args if state.pending_approval is not None else None,
268
+ )
269
+ suggestions = self.query_one("#command-suggestions", CommandSuggestions)
270
+ suggestions_allowed = (
271
+ not self._replan_mode
272
+ and state.phase != "awaiting_plan_review"
273
+ and state.pending_approval is None
274
+ and state.pending_confirmation is None
275
+ and self._modal_kind == ""
276
+ )
277
+ composer.set_suggestions_enabled(suggestions_allowed)
278
+ if not suggestions_allowed:
279
+ suggestions.close()
280
+ if state.pending_approval is not None:
281
+ if self._decision_mode == "approval_edit":
282
+ composer.placeholder = "修改参数:请输入完整 JSON,Esc 取消修改"
283
+ elif self._decision_mode == "approval_confirm_modified":
284
+ composer.placeholder = "输入 y 确认执行 · r 拒绝执行"
285
+ else:
286
+ composer.placeholder = "审批中:y 批准 · a 全部放行 · r 拒绝 · s 跳过 · e 修改参数"
287
+ elif state.pending_confirmation is not None:
288
+ if state.pending_confirmation.kind == "memory_clear":
289
+ composer.placeholder = "确认中:输入 clear 确认清空,其他输入取消"
290
+ else:
291
+ composer.placeholder = "确认中:输入 y 确认写入 · n 取消"
292
+ elif self._replan_mode:
293
+ composer.placeholder = "输入重新规划要求,Enter 提交"
294
+ elif state.phase == "awaiting_plan_review" and state.pending_plan is not None:
295
+ composer.placeholder = "按 Enter 执行 · r 重规划 · Esc 取消"
296
+ elif state.phase == "awaiting_team_input":
297
+ composer.placeholder = "输入 /team resume <任务ID> --write-scope <范围>,或 /cancel"
298
+ else:
299
+ composer.placeholder = "输入任务或 /help …"
300
+
301
+ def _render_state_immediately(self, state: TuiState) -> None:
302
+ """Render an explicit local UI action without an older queued state."""
303
+ if self._render_timer is not None:
304
+ self._render_timer.stop()
305
+ self._render_timer = None
306
+ self._pending_render_state = None
307
+ self._render_state(state)
308
+
309
+ # 决策文本协议:等待审批时只接受明确命令,其他输入不执行工具。
310
+ APPROVAL_TEXT_COMMANDS = {
311
+ "": "approve",
312
+ "y": "approve",
313
+ "yes": "approve",
314
+ "approve": "approve",
315
+ "a": "allow-all",
316
+ "allow-all": "allow-all",
317
+ "allow_all": "allow-all",
318
+ "r": "reject",
319
+ "n": "reject",
320
+ "no": "reject",
321
+ "reject": "reject",
322
+ "s": "skip",
323
+ "skip": "skip",
324
+ }
325
+
326
+ def handle_inline_approval(self, command: str, modified_args: dict | None = None) -> None:
327
+ decisions = {
328
+ "approve": ApprovalDecision(allow=True, reason="user_approved"),
329
+ "allow-all": ApprovalDecision(allow=True, reason="user_approved_allow_all"),
330
+ "allow_all": ApprovalDecision(allow=True, reason="user_approved_allow_all"),
331
+ "skip": ApprovalDecision(allow=False, reason="user_skipped"),
332
+ }
333
+ if command == "approve" and modified_args is not None:
334
+ decision = ApprovalDecision(allow=True, args=modified_args, reason="user_modified")
335
+ else:
336
+ decision = decisions.get(command, ApprovalDecision(allow=False, reason="user_rejected"))
337
+ self._decision_mode = ""
338
+ self._modified_args = None
339
+ asyncio.create_task(self.controller.approve_tool(decision))
340
+ self.query_one("#composer", Composer).focus()
341
+
342
+ def handle_inline_confirmation(self, confirmed: bool) -> None:
343
+ self._decision_mode = ""
344
+ self._modified_args = None
345
+ asyncio.create_task(self.controller.confirm_command(confirmed))
346
+ self.query_one("#composer", Composer).focus()
347
+
348
+ def _show_decision_hint(self, message: str) -> None:
349
+ """Surface a decision hint without touching controller state."""
350
+ self._state = replace(
351
+ self.controller.snapshot(),
352
+ notification=message,
353
+ notification_level="warning",
354
+ )
355
+ self._render_state_immediately(self._state)
356
+
357
+ def _route_decision_input(self, text: str, composer: Composer) -> bool:
358
+ """Consume the Composer text as a decision; True means handled.
359
+
360
+ Decision states take priority over normal submissions so text like
361
+ ``y`` or ``clear`` is never sent to the Agent as a user message.
362
+ """
363
+ state = self.controller.state
364
+ if self._decision_mode == "approval_edit":
365
+ self._handle_modified_args_json(text, composer)
366
+ return True
367
+ if self._decision_mode == "approval_confirm_modified":
368
+ self._handle_modified_args_confirmation(text, composer)
369
+ return True
370
+ if state.pending_approval is not None:
371
+ self._handle_approval_decision(text, composer)
372
+ return True
373
+ if state.pending_confirmation is not None:
374
+ self._handle_confirmation_decision(state.pending_confirmation.kind, text, composer)
375
+ return True
376
+ return False
377
+
378
+ def _handle_approval_decision(self, text: str, composer: Composer) -> None:
379
+ lowered = text.lower()
380
+ if lowered == "e":
381
+ composer.value = ""
382
+ self._decision_mode = "approval_edit"
383
+ self._show_decision_hint("参数修改:请输入完整 JSON,Esc 取消修改")
384
+ return
385
+ command = self.APPROVAL_TEXT_COMMANDS.get(lowered)
386
+ if command is None:
387
+ # 无效输入保留在输入框,审批状态不变,工具不执行。
388
+ self._show_decision_hint("审批中仅接受 y / a / r / s / e,本次输入未执行任何操作")
389
+ return
390
+ composer.value = ""
391
+ self.handle_inline_approval(command)
392
+
393
+ def _handle_modified_args_json(self, text: str, composer: Composer) -> None:
394
+ try:
395
+ parsed = json.loads(text) if text else None
396
+ except json.JSONDecodeError:
397
+ parsed = None
398
+ if not isinstance(parsed, dict):
399
+ self._show_decision_hint("JSON 无效:请输入完整的 JSON 对象,工具未执行")
400
+ return
401
+ self._modified_args = parsed
402
+ self._decision_mode = "approval_confirm_modified"
403
+ composer.value = ""
404
+ self._show_decision_hint("参数已修改,输入 y 确认执行 · r 拒绝执行")
405
+
406
+ def _handle_modified_args_confirmation(self, text: str, composer: Composer) -> None:
407
+ lowered = text.lower()
408
+ if lowered in ("y", "yes"):
409
+ composer.value = ""
410
+ self.handle_inline_approval("approve", self._modified_args)
411
+ return
412
+ if lowered in ("r", "n", "no"):
413
+ composer.value = ""
414
+ self.handle_inline_approval("reject")
415
+ return
416
+ self._show_decision_hint("输入 y 确认执行修改后的参数 · r 拒绝")
417
+
418
+ def _handle_confirmation_decision(self, kind: str, text: str, composer: Composer) -> None:
419
+ lowered = text.lower()
420
+ if kind == "memory_clear":
421
+ if lowered == "clear":
422
+ composer.value = ""
423
+ self.handle_inline_confirmation(True)
424
+ return
425
+ if lowered in ("cancel", "n", "no"):
426
+ composer.value = ""
427
+ self.handle_inline_confirmation(False)
428
+ return
429
+ self._show_decision_hint("清空长期记忆需精确输入 clear,本次输入未生效")
430
+ return
431
+ if lowered in ("y", "yes"):
432
+ composer.value = ""
433
+ self.handle_inline_confirmation(True)
434
+ return
435
+ if lowered in ("n", "no", "cancel"):
436
+ composer.value = ""
437
+ self.handle_inline_confirmation(False)
438
+ return
439
+ self._show_decision_hint("输入 y 确认写入 · n 取消,本次输入未生效")
440
+
441
+ def on_input_submitted(self, event: Composer.Submitted) -> None:
442
+ text = event.value.strip()
443
+ help_query = parse_help_command(text)
444
+ if help_query is not None:
445
+ event.input.value = ""
446
+ asyncio.create_task(self._submit_text(text))
447
+ return
448
+ if self._route_decision_input(text, event.input):
449
+ return
450
+ if self._has_pending_plan() and not self._replan_mode:
451
+ self._state = replace(
452
+ self.controller.snapshot(),
453
+ notification="当前处于计划审阅,请按 Enter 执行、r 重规划或 Esc 取消",
454
+ notification_level="info",
455
+ )
456
+ self._render_state(self._state)
457
+ return
458
+ event.input.value = ""
459
+ if text:
460
+ if self._replan_mode:
461
+ self._replan_mode = False
462
+ event.input.placeholder = "输入任务或 /help …"
463
+ asyncio.create_task(
464
+ self.controller.review_plan(ReviewDecision(action="replan", feedback=text))
465
+ )
466
+ else:
467
+ asyncio.create_task(self._submit_text(text))
468
+
469
+ async def _submit_text(self, text: str) -> None:
470
+ try:
471
+ accepted = await self.controller.submit(text)
472
+ composer = self.query_one("#composer", Composer)
473
+ if parse_help_command(text) is None:
474
+ composer.record_submission(text, accepted)
475
+ if not accepted:
476
+ if not composer.value:
477
+ composer.value = text
478
+ except asyncio.CancelledError:
479
+ raise
480
+ except Exception as exc:
481
+ self.notify(f"任务失败:{exc}", severity="error")
482
+ finally:
483
+ if text.lower() in ("/exit", "/quit"):
484
+ self.exit()
485
+
486
+ async def action_cancel_turn(self) -> None:
487
+ await self.controller.cancel()
488
+
489
+ async def action_clear_transcript(self) -> None:
490
+ await self.controller.submit("/clear")
491
+ self._state = self.controller.snapshot()
492
+ self._render_state_immediately(self._state)
493
+
494
+ def _has_pending_plan(self) -> bool:
495
+ return self.controller.state.phase == "awaiting_plan_review" and self.controller.state.pending_plan is not None
496
+
497
+ def _has_detail_toggle_target(self) -> bool:
498
+ return any(
499
+ item.collapsible
500
+ or (item.kind == "assistant" and "```mermaid" in item.text.lower())
501
+ for item in self.controller.state.transcript
502
+ )
503
+
504
+ def action_plan_execute(self) -> None:
505
+ if self._has_pending_plan() and not self._replan_mode:
506
+ self.query_one("#composer", Composer).value = ""
507
+ asyncio.create_task(self.controller.review_plan(ReviewDecision(action="execute")))
508
+
509
+ def action_plan_details(self) -> None:
510
+ if self._has_pending_plan() and not self._replan_mode:
511
+ self.controller.toggle_plan_details()
512
+ elif not self._replan_mode:
513
+ if not self.controller.toggle_diagram_source():
514
+ self.controller.toggle_latest_trace()
515
+
516
+ def action_trace_group(self) -> None:
517
+ if not self._has_pending_plan() and not self._replan_mode:
518
+ self.controller.toggle_trace_group()
519
+
520
+ def action_plan_replan(self) -> None:
521
+ if self._replan_mode:
522
+ return
523
+ if not self._has_pending_plan():
524
+ self.controller.refresh_diagrams()
525
+ return
526
+ self._replan_mode = True
527
+ composer = self.query_one("#composer", Composer)
528
+ composer.placeholder = "输入重新规划要求,Enter 提交"
529
+ composer.focus()
530
+ note = self.query_one("#notification", Static)
531
+ note.update("请输入重新规划要求,Enter 提交;Esc 取消计划")
532
+ note.display = True
533
+
534
+ async def action_escape(self) -> None:
535
+ """Give Escape a useful, state-aware meaning in every main-screen state."""
536
+ if self._has_pending_plan():
537
+ self._replan_mode = False
538
+ composer = self.query_one("#composer", Composer)
539
+ composer.value = ""
540
+ composer.placeholder = "输入任务或 /help …"
541
+ await self.controller.review_plan(ReviewDecision(action="cancel"))
542
+ return
543
+ composer = self.query_one("#composer", Composer)
544
+ if self._decision_mode == "approval_edit":
545
+ # Esc 取消修改,回到等待决策,不改变审批结果。
546
+ self._decision_mode = ""
547
+ self._modified_args = None
548
+ composer.value = ""
549
+ self._render_state_immediately(self.controller.snapshot())
550
+ return
551
+ state = self.controller.state
552
+ if state.pending_approval is not None:
553
+ # fail closed:Esc(含修改后二次确认)视为拒绝当前工具调用。
554
+ composer.value = ""
555
+ self.handle_inline_approval("reject")
556
+ return
557
+ if state.pending_confirmation is not None:
558
+ # 确认类操作按未确认处理,不执行写入或清空。
559
+ composer.value = ""
560
+ self.handle_inline_confirmation(False)
561
+ return
562
+ if self.controller.busy:
563
+ await self.controller.cancel()
564
+ return
565
+ composer.value = ""
566
+
567
+ def action_toggle_inspector(self) -> None:
568
+ inspector = self.query_one("#inspector", InspectorPanel)
569
+ inspector.display = not inspector.display
570
+
571
+ def action_inspector_session(self) -> None:
572
+ self.controller.set_inspector_view("session")
573
+
574
+ def action_inspector_plan(self) -> None:
575
+ self.controller.set_inspector_view("plan")
576
+
577
+ def action_inspector_memory(self) -> None:
578
+ self.controller.set_inspector_view("memory")
579
+
580
+ def action_inspector_safety(self) -> None:
581
+ self.controller.set_inspector_view("safety")
582
+
583
+ def action_inspector_next(self) -> None:
584
+ self.controller.cycle_inspector_view(1)
585
+
586
+ def action_inspector_previous(self) -> None:
587
+ self.controller.cycle_inspector_view(-1)
588
+
589
+ def action_config_panel(self) -> None:
590
+ from xg.tui.widgets.config_panel import open_config_screen
591
+
592
+ open_config_screen(self, self.controller.manager, self.settings)
593
+
594
+ async def on_unmount(self) -> None:
595
+ self._stop_progress_timer()
596
+ if self._render_timer is not None:
597
+ self._render_timer.stop()
598
+ self._render_timer = None
599
+ self._pending_render_state = None
600
+ await self.controller.shutdown()
601
+
602
+ def on_resize(self, event: Resize) -> None:
603
+ # Sidebar is useful on wide terminals and should never squeeze the
604
+ # conversation below a usable width.
605
+ self.query_one("#inspector", InspectorPanel).display = event.size.width >= 100
606
+ # DiagramCard is a width-aware Rich renderable. Request a layout-only
607
+ # refresh; content widgets and their DOM identities remain intact.
608
+ self.query_one("#transcript", TranscriptView).request_layout_refresh()
609
+
610
+
611
+ def run_tui(agent, settings: Settings, manager: ConfigManager) -> None:
612
+ XgTuiApp(agent, settings, manager).run()