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.
- xg/__init__.py +3 -0
- xg/__main__.py +3 -0
- xg/adaptive/__init__.py +27 -0
- xg/adaptive/calibrate.py +191 -0
- xg/adaptive/feedback.py +170 -0
- xg/adaptive/learned_rules.py +284 -0
- xg/adaptive/signals.py +125 -0
- xg/adaptive/store.py +164 -0
- xg/agent/__init__.py +0 -0
- xg/agent/plan.py +631 -0
- xg/agent/react.py +268 -0
- xg/agent/team.py +1793 -0
- xg/assets/router.lgb +0 -0
- xg/assets/router_semantics.json +21278 -0
- xg/assets/router_semantics.onnx +0 -0
- xg/cli/__init__.py +0 -0
- xg/cli/app.py +1372 -0
- xg/cli/commands.py +921 -0
- xg/cli/completion.py +740 -0
- xg/cli/help.py +185 -0
- xg/cli/train.py +200 -0
- xg/config/__init__.py +0 -0
- xg/config/env_writer.py +121 -0
- xg/config/manager.py +446 -0
- xg/config/mcp.py +229 -0
- xg/config/provider_service.py +267 -0
- xg/config/providers.py +46 -0
- xg/config/settings.py +258 -0
- xg/config/skills.py +100 -0
- xg/config/smart_router_service.py +121 -0
- xg/config/web.py +140 -0
- xg/input_history/__init__.py +7 -0
- xg/input_history/models.py +28 -0
- xg/input_history/persistence.py +126 -0
- xg/input_history/policy.py +39 -0
- xg/input_history/prompt_toolkit.py +36 -0
- xg/input_history/store.py +118 -0
- xg/llm/__init__.py +0 -0
- xg/llm/client.py +49 -0
- xg/llm/factory.py +40 -0
- xg/llm/openai_compat.py +275 -0
- xg/llm/types.py +98 -0
- xg/mcp/__init__.py +4 -0
- xg/mcp/http.py +192 -0
- xg/mcp/manager.py +726 -0
- xg/mcp/models.py +86 -0
- xg/mcp/protocol.py +62 -0
- xg/mcp/resources.py +72 -0
- xg/mcp/schema.py +137 -0
- xg/mcp/stdio.py +210 -0
- xg/mcp/transport.py +66 -0
- xg/memory/__init__.py +15 -0
- xg/memory/context.py +327 -0
- xg/memory/manager.py +111 -0
- xg/memory/models.py +41 -0
- xg/memory/project.py +187 -0
- xg/memory/store.py +144 -0
- xg/router/__init__.py +124 -0
- xg/router/features.py +66 -0
- xg/router/keywords.py +50 -0
- xg/router/ml_router.py +178 -0
- xg/router/model_tiers.py +73 -0
- xg/router/postprocess.py +167 -0
- xg/router/rule_router.py +77 -0
- xg/router/semantic.py +138 -0
- xg/safety/__init__.py +0 -0
- xg/safety/audit.py +96 -0
- xg/safety/guards.py +106 -0
- xg/safety/hitl.py +73 -0
- xg/skill/__init__.py +9 -0
- xg/skill/errors.py +45 -0
- xg/skill/loader.py +42 -0
- xg/skill/models.py +57 -0
- xg/skill/parser.py +93 -0
- xg/skill/policy.py +40 -0
- xg/skill/prompt.py +45 -0
- xg/skill/registry.py +169 -0
- xg/tool/__init__.py +0 -0
- xg/tool/builtin.py +356 -0
- xg/tool/registry.py +228 -0
- xg/tui/__init__.py +34 -0
- xg/tui/app.py +612 -0
- xg/tui/controller.py +1296 -0
- xg/tui/diagrams/__init__.py +22 -0
- xg/tui/diagrams/layout.py +110 -0
- xg/tui/diagrams/markdown.py +39 -0
- xg/tui/diagrams/model.py +36 -0
- xg/tui/diagrams/parser.py +119 -0
- xg/tui/diagrams/renderer.py +551 -0
- xg/tui/i18n.py +169 -0
- xg/tui/messages.py +45 -0
- xg/tui/plan_renderables.py +147 -0
- xg/tui/reducer.py +1029 -0
- xg/tui/renderables.py +252 -0
- xg/tui/state.py +240 -0
- xg/tui/theme.tcss +208 -0
- xg/tui/widgets/__init__.py +1 -0
- xg/tui/widgets/action_card.py +94 -0
- xg/tui/widgets/agent_group_card.py +39 -0
- xg/tui/widgets/approval_modal.py +59 -0
- xg/tui/widgets/collapsible_card.py +40 -0
- xg/tui/widgets/command_suggestions.py +128 -0
- xg/tui/widgets/composer.py +151 -0
- xg/tui/widgets/config_panel.py +198 -0
- xg/tui/widgets/confirm_modal.py +31 -0
- xg/tui/widgets/footer.py +9 -0
- xg/tui/widgets/header.py +118 -0
- xg/tui/widgets/inspector.py +378 -0
- xg/tui/widgets/plan_modal.py +53 -0
- xg/tui/widgets/provider_form.py +141 -0
- xg/tui/widgets/queue_status.py +30 -0
- xg/tui/widgets/smart_router_form.py +95 -0
- xg/tui/widgets/transcript.py +354 -0
- xg/tui/workers.py +14 -0
- xg/web/__init__.py +21 -0
- xg/web/errors.py +57 -0
- xg/web/extract.py +127 -0
- xg/web/fetch.py +106 -0
- xg/web/markdown.py +77 -0
- xg/web/models.py +91 -0
- xg/web/providers.py +79 -0
- xg/web/search.py +118 -0
- xg/web/searxng.py +27 -0
- xg/web/serpapi.py +29 -0
- xg/web/url_policy.py +110 -0
- xg/web/zhipu.py +27 -0
- xg_cli-1.0.dist-info/METADATA +284 -0
- xg_cli-1.0.dist-info/RECORD +130 -0
- xg_cli-1.0.dist-info/WHEEL +4 -0
- 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()
|