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
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
"""The read-only, multi-view Inspector shown beside the transcript."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from textual.containers import Horizontal, Vertical
|
|
6
|
+
from textual.events import Click
|
|
7
|
+
from textual.widgets import ContentSwitcher, Static
|
|
8
|
+
from rich.text import Text
|
|
9
|
+
|
|
10
|
+
from xg.tui.messages import InspectorViewSelected
|
|
11
|
+
from xg.tui.i18n import UiLanguage, normalize_language, translate, translate_status
|
|
12
|
+
from xg.tui.state import InspectorView, TuiState
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _number(value: int, *, unavailable: bool = False) -> str:
|
|
16
|
+
return "-" if unavailable else f"{value:,}"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _ratio(value: float, *, unavailable: bool = False) -> str:
|
|
20
|
+
return "-" if unavailable else f"{value * 100:.1f}%"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _clip(value: str, width: int = 18) -> str:
|
|
24
|
+
value = " ".join(value.split())
|
|
25
|
+
if len(value) <= width:
|
|
26
|
+
return value
|
|
27
|
+
return value[: max(1, width - 1)] + "…"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
INSPECTOR_STYLES = {
|
|
31
|
+
"title": "bold bright_cyan",
|
|
32
|
+
"section": "bold cyan",
|
|
33
|
+
"label": "dim",
|
|
34
|
+
"value": "white",
|
|
35
|
+
"metric": "bright_cyan",
|
|
36
|
+
"info": "blue",
|
|
37
|
+
"running": "yellow",
|
|
38
|
+
"success": "green",
|
|
39
|
+
"warning": "yellow",
|
|
40
|
+
"error": "red",
|
|
41
|
+
"empty": "dim",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
STATUS_STYLES = {
|
|
45
|
+
"idle": ("○", "empty"),
|
|
46
|
+
"running": ("●", "running"),
|
|
47
|
+
"working": ("●", "running"),
|
|
48
|
+
"waiting": ("◐", "warning"),
|
|
49
|
+
"waiting approval": ("◐", "warning"),
|
|
50
|
+
"review": ("◐", "info"),
|
|
51
|
+
"plan review": ("◐", "info"),
|
|
52
|
+
"done": ("✓", "success"),
|
|
53
|
+
"success": ("✓", "success"),
|
|
54
|
+
"approved": ("✓", "success"),
|
|
55
|
+
"active": ("✓", "success"),
|
|
56
|
+
"on": ("✓", "success"),
|
|
57
|
+
"loaded": ("✓", "success"),
|
|
58
|
+
"available": ("✓", "success"),
|
|
59
|
+
"no": ("✓", "success"),
|
|
60
|
+
"failed": ("!", "error"),
|
|
61
|
+
"error": ("!", "error"),
|
|
62
|
+
"rejected": ("!", "error"),
|
|
63
|
+
"denied": ("!", "error"),
|
|
64
|
+
"off": ("!", "warning"),
|
|
65
|
+
"yes": ("!", "warning"),
|
|
66
|
+
"cancelled": ("×", "warning"),
|
|
67
|
+
"blocked": ("!", "warning"),
|
|
68
|
+
"skipped": ("→", "empty"),
|
|
69
|
+
"inactive": ("!", "warning"),
|
|
70
|
+
"unavailable": ("×", "error"),
|
|
71
|
+
"not found": ("-", "empty"),
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _status_style(status: str, *, fallback: str = "value") -> tuple[str, str]:
|
|
76
|
+
return STATUS_STYLES.get(status.lower(), ("·", fallback))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _metric_style(ratio: float, *, available: bool) -> str:
|
|
80
|
+
if not available:
|
|
81
|
+
return INSPECTOR_STYLES["empty"]
|
|
82
|
+
if ratio >= 0.9:
|
|
83
|
+
return INSPECTOR_STYLES["error"]
|
|
84
|
+
if ratio >= 0.7:
|
|
85
|
+
return INSPECTOR_STYLES["warning"]
|
|
86
|
+
return INSPECTOR_STYLES["metric"]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _bar_parts(done: int, total: int, width: int = 10) -> tuple[str, str] | None:
|
|
90
|
+
if total <= 0:
|
|
91
|
+
return None
|
|
92
|
+
filled = min(width, max(0, round(done / total * width)))
|
|
93
|
+
return "█" * filled, "░" * (width - filled)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class InspectorTab(Static):
|
|
97
|
+
"""A stable, keyboard and mouse accessible Inspector tab."""
|
|
98
|
+
|
|
99
|
+
can_focus = True
|
|
100
|
+
BINDINGS = [
|
|
101
|
+
("enter", "select", "选择视图"),
|
|
102
|
+
("space", "select", "选择视图"),
|
|
103
|
+
]
|
|
104
|
+
|
|
105
|
+
def __init__(self, view: InspectorView, label: str, *, id: str) -> None:
|
|
106
|
+
self.view = view
|
|
107
|
+
self.full_label = label
|
|
108
|
+
super().__init__(label, id=id, markup=False)
|
|
109
|
+
self.tooltip = label
|
|
110
|
+
|
|
111
|
+
def on_click(self, event: Click) -> None:
|
|
112
|
+
event.stop()
|
|
113
|
+
self.post_message(InspectorViewSelected(self.view))
|
|
114
|
+
|
|
115
|
+
def action_select(self) -> None:
|
|
116
|
+
self.post_message(InspectorViewSelected(self.view))
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class InspectorPanel(Vertical):
|
|
120
|
+
"""Four read-only views backed exclusively by :class:`TuiState`."""
|
|
121
|
+
|
|
122
|
+
VIEWS: tuple[InspectorView, ...] = ("session", "plan", "memory", "safety")
|
|
123
|
+
LABELS = {
|
|
124
|
+
"session": "Session",
|
|
125
|
+
"plan": "Plan",
|
|
126
|
+
"memory": "Memory",
|
|
127
|
+
"safety": "Safety",
|
|
128
|
+
}
|
|
129
|
+
def __init__(self, *args, language: UiLanguage = "en", **kwargs) -> None:
|
|
130
|
+
self.language = normalize_language(language)
|
|
131
|
+
self._last_state: TuiState | None = None
|
|
132
|
+
super().__init__(*args, **kwargs)
|
|
133
|
+
# Kept for direct rendering tests and compatibility with callers that
|
|
134
|
+
# previously treated InspectorPanel as a Static.
|
|
135
|
+
self._rendered_text: Text | str = ""
|
|
136
|
+
|
|
137
|
+
def compose(self):
|
|
138
|
+
with Horizontal(id="inspector-tabs"):
|
|
139
|
+
for view in self.VIEWS:
|
|
140
|
+
yield InspectorTab(
|
|
141
|
+
view,
|
|
142
|
+
translate(self.language, f"view.{view}"),
|
|
143
|
+
id=f"inspector-tab-{view}",
|
|
144
|
+
)
|
|
145
|
+
with ContentSwitcher(initial="inspector-session", id="inspector-content"):
|
|
146
|
+
for view in self.VIEWS:
|
|
147
|
+
yield Static("", id=f"inspector-{view}", markup=False)
|
|
148
|
+
|
|
149
|
+
def render(self):
|
|
150
|
+
# ``InspectorPanel`` used to be a Static. Returning the last active
|
|
151
|
+
# view keeps standalone render() consumers useful while mounted
|
|
152
|
+
# children provide the actual on-screen layout.
|
|
153
|
+
if self.is_mounted:
|
|
154
|
+
return ""
|
|
155
|
+
return self._rendered_text
|
|
156
|
+
|
|
157
|
+
def update_state(self, state: TuiState) -> None:
|
|
158
|
+
self._last_state = state
|
|
159
|
+
self.language = normalize_language(state.ui_language)
|
|
160
|
+
if self.is_mounted:
|
|
161
|
+
self._update_tab_labels()
|
|
162
|
+
inspector = state.inspector
|
|
163
|
+
view = inspector.active_view if inspector.active_view in self.VIEWS else "session"
|
|
164
|
+
self._rendered_text = self._view_text(view, state)
|
|
165
|
+
if not self.is_mounted:
|
|
166
|
+
return
|
|
167
|
+
|
|
168
|
+
for candidate in self.VIEWS:
|
|
169
|
+
tab = self.query_one(f"#inspector-tab-{candidate}", InspectorTab)
|
|
170
|
+
tab.remove_class("active")
|
|
171
|
+
if candidate == view:
|
|
172
|
+
tab.add_class("active")
|
|
173
|
+
content = self.query_one(f"#inspector-{candidate}", Static)
|
|
174
|
+
content.update(self._view_text(candidate, state))
|
|
175
|
+
self.query_one("#inspector-content", ContentSwitcher).current = f"inspector-{view}"
|
|
176
|
+
|
|
177
|
+
def set_language(self, language: UiLanguage) -> None:
|
|
178
|
+
"""Change display language and immediately redraw the last snapshot."""
|
|
179
|
+
self.language = normalize_language(language)
|
|
180
|
+
if self.is_mounted:
|
|
181
|
+
self._update_tab_labels()
|
|
182
|
+
if self._last_state is not None:
|
|
183
|
+
view = self._last_state.inspector.active_view
|
|
184
|
+
self._rendered_text = self._view_text(view, self._last_state)
|
|
185
|
+
if self.is_mounted:
|
|
186
|
+
for candidate in self.VIEWS:
|
|
187
|
+
content = self.query_one(f"#inspector-{candidate}", Static)
|
|
188
|
+
content.update(self._view_text(candidate, self._last_state))
|
|
189
|
+
self.query_one("#inspector-content", ContentSwitcher).current = f"inspector-{view}"
|
|
190
|
+
|
|
191
|
+
def _update_tab_labels(self) -> None:
|
|
192
|
+
for view in self.VIEWS:
|
|
193
|
+
tab = self.query_one(f"#inspector-tab-{view}", InspectorTab)
|
|
194
|
+
label = translate(self.language, f"view.{view}")
|
|
195
|
+
tab.update(label)
|
|
196
|
+
tab.full_label = label
|
|
197
|
+
tab.tooltip = label
|
|
198
|
+
|
|
199
|
+
def _view_text(self, view: InspectorView, state: TuiState) -> Text:
|
|
200
|
+
if view == "plan":
|
|
201
|
+
return self._plan_text(state)
|
|
202
|
+
if view == "memory":
|
|
203
|
+
return self._memory_text(state)
|
|
204
|
+
if view == "safety":
|
|
205
|
+
return self._safety_text(state)
|
|
206
|
+
return self._session_text(state)
|
|
207
|
+
|
|
208
|
+
def _t(self, key: str, **values: object) -> str:
|
|
209
|
+
return translate(self.language, key, **values)
|
|
210
|
+
|
|
211
|
+
@staticmethod
|
|
212
|
+
def _title(text: Text, value: str) -> None:
|
|
213
|
+
text.append(value + "\n", style=INSPECTOR_STYLES["title"])
|
|
214
|
+
|
|
215
|
+
@staticmethod
|
|
216
|
+
def _section(text: Text, value: str) -> None:
|
|
217
|
+
text.append(value + "\n", style=INSPECTOR_STYLES["section"])
|
|
218
|
+
|
|
219
|
+
@staticmethod
|
|
220
|
+
def _field(text: Text, label: str, value: str, *, style: str = "value") -> None:
|
|
221
|
+
text.append(f"{label:<17}", style=INSPECTOR_STYLES["label"])
|
|
222
|
+
text.append(value + "\n", style=INSPECTOR_STYLES.get(style, style))
|
|
223
|
+
|
|
224
|
+
def _status(self, text: Text, label: str, status: str, *, fallback: str = "value") -> None:
|
|
225
|
+
icon, style = _status_style(status, fallback=fallback)
|
|
226
|
+
text.append(f"{label:<17}", style=INSPECTOR_STYLES["label"])
|
|
227
|
+
text.append(
|
|
228
|
+
f"{icon} {translate_status(self.language, status)}\n",
|
|
229
|
+
style=INSPECTOR_STYLES.get(style, style),
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
@staticmethod
|
|
233
|
+
def _progress_field(
|
|
234
|
+
text: Text,
|
|
235
|
+
label: str,
|
|
236
|
+
done: int,
|
|
237
|
+
total: int,
|
|
238
|
+
suffix: str,
|
|
239
|
+
*,
|
|
240
|
+
ratio: float | None = None,
|
|
241
|
+
) -> None:
|
|
242
|
+
parts = _bar_parts(done, total)
|
|
243
|
+
text.append(f"{label:<17}", style=INSPECTOR_STYLES["label"])
|
|
244
|
+
if parts is None:
|
|
245
|
+
text.append("-\n", style=INSPECTOR_STYLES["empty"])
|
|
246
|
+
return
|
|
247
|
+
ratio = ratio if ratio is not None else done / total
|
|
248
|
+
style = _metric_style(ratio, available=True)
|
|
249
|
+
text.append(parts[0], style=style)
|
|
250
|
+
text.append(parts[1], style=INSPECTOR_STYLES["empty"])
|
|
251
|
+
text.append(f" {suffix}\n", style=style)
|
|
252
|
+
|
|
253
|
+
def _session_text(self, state: TuiState) -> Text:
|
|
254
|
+
inspector = state.inspector
|
|
255
|
+
usage = inspector.usage
|
|
256
|
+
status = {
|
|
257
|
+
"idle": "Idle",
|
|
258
|
+
"running": "Working",
|
|
259
|
+
"awaiting_approval": "Waiting approval",
|
|
260
|
+
"awaiting_plan_review": "Plan review",
|
|
261
|
+
"awaiting_team_input": "Waiting team input",
|
|
262
|
+
"error": "Error",
|
|
263
|
+
}.get(state.phase, state.phase)
|
|
264
|
+
text = Text()
|
|
265
|
+
self._title(text, self._t("view.session"))
|
|
266
|
+
self._field(text, self._t("label.provider"), inspector.provider or "-", style="value" if inspector.provider else "empty")
|
|
267
|
+
self._field(text, self._t("label.model"), inspector.model or "-", style="value" if inspector.model else "empty")
|
|
268
|
+
self._status(text, self._t("label.status"), status, fallback="value")
|
|
269
|
+
text.append("\n")
|
|
270
|
+
self._section(text, self._t("section.context"))
|
|
271
|
+
self._field(text, self._t("label.estimated_input"), f"{_number(usage.estimated_prompt_tokens, unavailable=usage.estimated_prompt_tokens <= 0)} {self._t('unit.token')}", style="metric")
|
|
272
|
+
self._field(text, self._t("label.model_window"), f"{_number(usage.context_window, unavailable=usage.context_window <= 0)} {self._t('unit.token')}", style="metric")
|
|
273
|
+
window_available = usage.context_window > 0
|
|
274
|
+
self._progress_field(
|
|
275
|
+
text, self._t("label.window_usage"), usage.estimated_prompt_tokens, usage.context_window,
|
|
276
|
+
_ratio(usage.window_ratio, unavailable=not window_available), ratio=usage.window_ratio,
|
|
277
|
+
)
|
|
278
|
+
self._field(text, self._t("label.input_budget"), f"{_number(usage.request_token_limit, unavailable=usage.request_token_limit <= 0)} {self._t('unit.token')}", style="metric")
|
|
279
|
+
budget_available = usage.request_token_limit > 0
|
|
280
|
+
self._progress_field(
|
|
281
|
+
text, self._t("label.budget_usage"), usage.estimated_prompt_tokens, usage.request_token_limit,
|
|
282
|
+
_ratio(usage.budget_usage_ratio, unavailable=not budget_available), ratio=usage.budget_usage_ratio,
|
|
283
|
+
)
|
|
284
|
+
self._field(
|
|
285
|
+
text,
|
|
286
|
+
self._t("label.source"),
|
|
287
|
+
translate_status(self.language, usage.usage_source),
|
|
288
|
+
style="info" if usage.usage_source != "unavailable" else "empty",
|
|
289
|
+
)
|
|
290
|
+
text.append("\n")
|
|
291
|
+
self._section(text, self._t("section.last_request"))
|
|
292
|
+
provider_usage = usage.usage_source == "provider"
|
|
293
|
+
self._field(text, self._t("label.prompt"), _number(usage.last_prompt_tokens, unavailable=not provider_usage), style="metric")
|
|
294
|
+
self._field(text, self._t("label.completion"), _number(usage.last_completion_tokens, unavailable=not provider_usage), style="metric")
|
|
295
|
+
self._field(text, self._t("label.total"), _number(usage.last_total_tokens, unavailable=not provider_usage), style="metric")
|
|
296
|
+
text.append("\n")
|
|
297
|
+
self._section(text, self._t("section.session_usage"))
|
|
298
|
+
self._field(text, self._t("label.prompt"), _number(usage.session_prompt_tokens), style="metric")
|
|
299
|
+
self._field(text, self._t("label.completion"), _number(usage.session_completion_tokens), style="metric")
|
|
300
|
+
self._field(text, self._t("label.total"), _number(usage.session_total_tokens), style="metric")
|
|
301
|
+
text.append("\n")
|
|
302
|
+
self._section(text, self._t("section.compaction"))
|
|
303
|
+
self._field(text, self._t("label.count"), str(usage.compaction_count), style="metric")
|
|
304
|
+
self._field(text, self._t("label.last"), f"{_number(usage.last_compaction_before)} → {_number(usage.last_compaction_after)}", style="metric")
|
|
305
|
+
return text
|
|
306
|
+
|
|
307
|
+
def _plan_text(self, state: TuiState) -> Text:
|
|
308
|
+
plan = state.inspector.plan
|
|
309
|
+
round_text = (
|
|
310
|
+
self._t("unit.round", current=plan.current_round, total=plan.total_rounds)
|
|
311
|
+
if plan.current_round > 0 and plan.total_rounds > 0 else "-"
|
|
312
|
+
)
|
|
313
|
+
text = Text()
|
|
314
|
+
self._title(text, self._t("view.plan"))
|
|
315
|
+
self._status(text, self._t("label.status"), plan.status, fallback="value")
|
|
316
|
+
self._field(text, self._t("label.goal"), plan.goal or "-", style="value" if plan.goal else "empty")
|
|
317
|
+
self._field(text, self._t("label.current_round"), round_text, style="metric" if round_text != "-" else "empty")
|
|
318
|
+
self._progress_field(text, self._t("label.progress"), plan.completed_tasks, plan.total_tasks, f"{plan.completed_tasks} / {plan.total_tasks} {self._t('unit.tasks')}")
|
|
319
|
+
failures_style = "error" if plan.failure_count > 0 else "success"
|
|
320
|
+
self._field(text, self._t("label.failures"), str(plan.failure_count), style=failures_style)
|
|
321
|
+
text.append("\n")
|
|
322
|
+
self._section(text, self._t("section.tasks"))
|
|
323
|
+
if plan.tasks:
|
|
324
|
+
for task in plan.tasks:
|
|
325
|
+
icon, style = _status_style(task.status)
|
|
326
|
+
text.append(f"{icon} ", style=INSPECTOR_STYLES.get(style, style))
|
|
327
|
+
text.append(f"{task.id:<5}", style=INSPECTOR_STYLES["metric"])
|
|
328
|
+
text.append(f"{_clip(task.title):<18}", style=INSPECTOR_STYLES["value"])
|
|
329
|
+
text.append(
|
|
330
|
+
translate_status(self.language, task.status) + "\n",
|
|
331
|
+
style=INSPECTOR_STYLES.get(style, style),
|
|
332
|
+
)
|
|
333
|
+
else:
|
|
334
|
+
text.append(self._t("empty.no_plan") + "\n", style=INSPECTOR_STYLES["empty"])
|
|
335
|
+
return text
|
|
336
|
+
|
|
337
|
+
def _memory_text(self, state: TuiState) -> Text:
|
|
338
|
+
memory = state.inspector.memory
|
|
339
|
+
text = Text()
|
|
340
|
+
self._title(text, self._t("view.memory"))
|
|
341
|
+
self._field(text, self._t("label.project_root"), memory.project_root or "-", style="value" if memory.project_root else "empty")
|
|
342
|
+
text.append("\n")
|
|
343
|
+
self._section(text, self._t("section.project_memory"))
|
|
344
|
+
self._status(text, "XG.md", "loaded" if memory.xg_loaded else "not found", fallback="empty")
|
|
345
|
+
self._status(text, "XG.local.md", "loaded" if memory.xg_local_loaded else "not found", fallback="empty")
|
|
346
|
+
warning_style = "warning" if memory.warning_count > 0 else "success"
|
|
347
|
+
self._field(text, self._t("label.warnings"), str(memory.warning_count), style=warning_style)
|
|
348
|
+
text.append("\n")
|
|
349
|
+
self._section(text, self._t("section.long_term_memory"))
|
|
350
|
+
self._field(text, self._t("label.entries"), str(memory.memory_count), style="metric")
|
|
351
|
+
self._field(text, self._t("label.last_operation"), memory.last_operation or "-", style="info" if memory.last_operation else "empty")
|
|
352
|
+
self._status(text, self._t("label.store"), "available" if memory.store_available else "unavailable", fallback="value")
|
|
353
|
+
return text
|
|
354
|
+
|
|
355
|
+
def _safety_text(self, state: TuiState) -> Text:
|
|
356
|
+
safety = state.inspector.safety
|
|
357
|
+
text = Text()
|
|
358
|
+
self._title(text, self._t("view.safety"))
|
|
359
|
+
self._status(text, "HITL", "on" if safety.hitl_enabled else "off", fallback="value")
|
|
360
|
+
self._status(text, self._t("label.session_allow_all"), "yes" if safety.session_allow_all else "no", fallback="value")
|
|
361
|
+
text.append("\n")
|
|
362
|
+
self._section(text, self._t("section.current_approval"))
|
|
363
|
+
self._status(text, self._t("label.status"), safety.approval_status, fallback="value")
|
|
364
|
+
self._field(text, self._t("label.tool"), safety.current_tool or "-", style="value" if safety.current_tool else "empty")
|
|
365
|
+
self._field(text, self._t("label.level"), safety.current_level or "-", style="value" if safety.current_level else "empty")
|
|
366
|
+
text.append("\n")
|
|
367
|
+
self._section(text, self._t("section.last_decision"))
|
|
368
|
+
if safety.last_decision:
|
|
369
|
+
self._status(text, self._t("label.decision"), safety.last_decision, fallback="value")
|
|
370
|
+
else:
|
|
371
|
+
self._field(text, self._t("label.decision"), "-", style="empty")
|
|
372
|
+
self._field(text, self._t("label.reason"), safety.last_reason or "-", style="value" if safety.last_reason else "empty")
|
|
373
|
+
text.append("\n")
|
|
374
|
+
self._section(text, self._t("section.policy"))
|
|
375
|
+
self._status(text, "PathGuard", "active" if safety.path_guard_active else "inactive", fallback="value")
|
|
376
|
+
self._status(text, "CommandGuard", "active" if safety.command_guard_active else "inactive", fallback="value")
|
|
377
|
+
self._field(text, self._t("label.last_rejection"), safety.last_rejection or "-", style="error" if safety.last_rejection else "empty")
|
|
378
|
+
return text
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from textual.app import ComposeResult
|
|
4
|
+
from textual.containers import Vertical
|
|
5
|
+
from textual.screen import ModalScreen
|
|
6
|
+
from textual.widgets import Button, Input, Static
|
|
7
|
+
|
|
8
|
+
from xg.agent.plan import Plan, ReviewDecision
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class PlanModal(ModalScreen[str]):
|
|
12
|
+
BINDINGS = [("escape", "cancel", "取消"), ("enter", "execute", "执行"), ("d", "details", "详情"), ("r", "replan", "重新规划")]
|
|
13
|
+
|
|
14
|
+
def __init__(self, plan: Plan) -> None:
|
|
15
|
+
super().__init__()
|
|
16
|
+
self.plan = plan
|
|
17
|
+
self._details = False
|
|
18
|
+
|
|
19
|
+
def compose(self) -> ComposeResult:
|
|
20
|
+
yield from self._content()
|
|
21
|
+
|
|
22
|
+
def _content(self) -> list:
|
|
23
|
+
lines = [f"目标:{self.plan.goal}", f"任务:{len(self.plan.tasks)} 个,共 {len(self.plan.batches)} 轮"]
|
|
24
|
+
for round_no, batch in enumerate(self.plan.batches, 1):
|
|
25
|
+
lines.append(f"第 {round_no} 轮")
|
|
26
|
+
for task_id in batch:
|
|
27
|
+
task = self.plan.task_by_id(task_id)
|
|
28
|
+
if task is None:
|
|
29
|
+
continue
|
|
30
|
+
lines.append(f"{task.id} [{task.status}] {task.title}")
|
|
31
|
+
if self._details:
|
|
32
|
+
lines.append(f" {task.description}")
|
|
33
|
+
return [Vertical(Static("\n".join(lines)), Input(placeholder="按 r 输入重新规划要求", id="plan-feedback"), Button("执行 (Enter)", id="execute", variant="success"), Button("取消 (Esc)", id="cancel", variant="error"))]
|
|
34
|
+
|
|
35
|
+
def action_details(self) -> None:
|
|
36
|
+
self._details = not self._details
|
|
37
|
+
self.refresh(recompose=True)
|
|
38
|
+
|
|
39
|
+
def action_cancel(self) -> None:
|
|
40
|
+
self.dismiss("cancel")
|
|
41
|
+
|
|
42
|
+
def action_execute(self) -> None:
|
|
43
|
+
self.dismiss("execute")
|
|
44
|
+
|
|
45
|
+
def action_replan(self) -> None:
|
|
46
|
+
self.query_one("#plan-feedback", Input).focus()
|
|
47
|
+
|
|
48
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
49
|
+
if event.input.id == "plan-feedback" and event.value.strip():
|
|
50
|
+
self.dismiss("replan:" + event.value.strip())
|
|
51
|
+
|
|
52
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
53
|
+
self.dismiss(event.button.id or "cancel")
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Provider 新增/编辑/写 Key 表单 Modal(TUI)。
|
|
2
|
+
|
|
3
|
+
仅于 TUI 运行时 import。字段含即时校验;API Key 为密码型输入,
|
|
4
|
+
改写前经 :meth:`ProviderConfigService.set_api_key` 的覆盖确认与脱敏。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from textual.app import ComposeResult
|
|
10
|
+
from textual.containers import Horizontal, Vertical
|
|
11
|
+
from textual.screen import ModalScreen
|
|
12
|
+
from textual.widgets import Button, Input, Static
|
|
13
|
+
|
|
14
|
+
from xg.config.provider_service import (
|
|
15
|
+
OpResult,
|
|
16
|
+
ProviderConfigService,
|
|
17
|
+
validate_api_base,
|
|
18
|
+
validate_model,
|
|
19
|
+
validate_name,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ProviderForm(ModalScreen[None]):
|
|
24
|
+
"""mode: ``add`` / ``edit`` / ``key``。"""
|
|
25
|
+
|
|
26
|
+
BINDINGS = [("escape", "cancel", "取消")]
|
|
27
|
+
|
|
28
|
+
def __init__(self, service: ProviderConfigService, *, mode: str, name: str | None = None) -> None:
|
|
29
|
+
super().__init__()
|
|
30
|
+
self.service = service
|
|
31
|
+
self.mode = mode
|
|
32
|
+
# 用独立属性承载目标 provider 名,避开 ModalScreen.name 只读属性
|
|
33
|
+
self._target = name
|
|
34
|
+
self._row = service.get(name) if name else None
|
|
35
|
+
|
|
36
|
+
def compose(self) -> ComposeResult:
|
|
37
|
+
title = {"add": "新增 Provider", "edit": "编辑 Provider", "key": "写入 API Key"}[self.mode]
|
|
38
|
+
with Vertical(id="provider-form"):
|
|
39
|
+
yield Static(title, id="provider-form-title")
|
|
40
|
+
if self.mode in ("add", "edit"):
|
|
41
|
+
yield Static("名称", id="f-name-label")
|
|
42
|
+
yield Input(
|
|
43
|
+
value=self._target or "", id="name",
|
|
44
|
+
placeholder="仅字母数字下划线连字符,不含空格",
|
|
45
|
+
disabled=self.mode == "edit",
|
|
46
|
+
)
|
|
47
|
+
if self.mode == "add":
|
|
48
|
+
yield Static("api_base", id="f-base-label")
|
|
49
|
+
yield Input(value=(self._row or {}).get("api_base", ""), id="api_base",
|
|
50
|
+
placeholder="https://gateway.my.com/v1")
|
|
51
|
+
yield Static("default_model", id="f-model-label")
|
|
52
|
+
yield Input(value=(self._row or {}).get("default_model", ""), id="default_model")
|
|
53
|
+
yield Static("display_name(可选)", id="f-label-label")
|
|
54
|
+
yield Input(value=(self._row or {}).get("display_name", ""), id="display_name")
|
|
55
|
+
yield Static("API Key(可选,稍后也可用「写Key」)", id="f-key-label")
|
|
56
|
+
yield Input(value="", id="api_key", password=True, placeholder="XG_{NAME}_API_KEY")
|
|
57
|
+
elif self.mode == "edit":
|
|
58
|
+
yield Static("api_base", id="f-base-label")
|
|
59
|
+
yield Input(value=(self._row or {}).get("api_base", ""), id="api_base")
|
|
60
|
+
yield Static("default_model", id="f-model-label")
|
|
61
|
+
yield Input(value=(self._row or {}).get("default_model", ""), id="default_model")
|
|
62
|
+
yield Static("models(逗号分隔,覆盖式同步)", id="f-models-label")
|
|
63
|
+
yield Input(value=", ".join((self._row or {}).get("models") or []), id="models")
|
|
64
|
+
yield Static("display_name(可选)", id="f-label-label")
|
|
65
|
+
yield Input(value=(self._row or {}).get("display_name", ""), id="display_name")
|
|
66
|
+
elif self.mode == "key":
|
|
67
|
+
yield Static(f"API Key({self._target})", id="f-key-label")
|
|
68
|
+
yield Input(value="", id="api_key", password=True, placeholder=f"XG_{self._target.upper()}_API_KEY")
|
|
69
|
+
with Horizontal(id="provider-form-actions"):
|
|
70
|
+
yield Button("保存", id="save", variant="primary")
|
|
71
|
+
yield Button("取消", id="cancel")
|
|
72
|
+
|
|
73
|
+
def action_cancel(self) -> None:
|
|
74
|
+
self.dismiss(None)
|
|
75
|
+
|
|
76
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
77
|
+
if event.button.id == "cancel":
|
|
78
|
+
self.dismiss(None)
|
|
79
|
+
return
|
|
80
|
+
if event.button.id == "save":
|
|
81
|
+
self._save()
|
|
82
|
+
return
|
|
83
|
+
# Enter 在 Input 上触发同一保存逻辑
|
|
84
|
+
if event.button.id == "" and self.mode in ("add", "edit", "key"):
|
|
85
|
+
self._save()
|
|
86
|
+
|
|
87
|
+
def _sync_models(self, name: str, raw: str, current: list[str]) -> None:
|
|
88
|
+
"""把编辑表单的模型串(逗号分隔)覆盖式同步到 provider 模型列表。"""
|
|
89
|
+
seen: set[str] = set()
|
|
90
|
+
desired = []
|
|
91
|
+
for m in raw.split(","):
|
|
92
|
+
m = m.strip()
|
|
93
|
+
if m and m not in seen:
|
|
94
|
+
seen.add(m)
|
|
95
|
+
desired.append(m)
|
|
96
|
+
for m in [x for x in current if x not in desired]:
|
|
97
|
+
self.service.remove_model(name, m)
|
|
98
|
+
for m in desired:
|
|
99
|
+
self.service.add_model(name, m)
|
|
100
|
+
|
|
101
|
+
def _save(self) -> None:
|
|
102
|
+
if self.mode == "add":
|
|
103
|
+
name = self.query_one("#name", Input).value.strip()
|
|
104
|
+
base = self.query_one("#api_base", Input).value.strip()
|
|
105
|
+
model = self.query_one("#default_model", Input).value.strip()
|
|
106
|
+
label = self.query_one("#display_name", Input).value.strip() or None
|
|
107
|
+
key = self.query_one("#api_key", Input).value.strip()
|
|
108
|
+
err = validate_name(name) or validate_api_base(base)
|
|
109
|
+
if err:
|
|
110
|
+
return self.notify(err, severity="error")
|
|
111
|
+
result = self.service.add(name, base, model, display_name=label)
|
|
112
|
+
if result.ok and key:
|
|
113
|
+
key_result = self.service.set_api_key(name, key, overwrite=True, yes=True)
|
|
114
|
+
result = OpResult(
|
|
115
|
+
True,
|
|
116
|
+
f"{result.message};{key_result.message}",
|
|
117
|
+
) if key_result.ok else key_result
|
|
118
|
+
elif self.mode == "edit":
|
|
119
|
+
base = self.query_one("#api_base", Input).value.strip()
|
|
120
|
+
model = self.query_one("#default_model", Input).value.strip()
|
|
121
|
+
label = self.query_one("#display_name", Input).value.strip() or None
|
|
122
|
+
err = validate_api_base(base) or validate_model(model)
|
|
123
|
+
if not model:
|
|
124
|
+
err = err or "default_model 必填"
|
|
125
|
+
if err:
|
|
126
|
+
return self.notify(err, severity="error")
|
|
127
|
+
result = self.service.update(
|
|
128
|
+
self._target, {"api_base": base, "default_model": model, "display_name": label}
|
|
129
|
+
)
|
|
130
|
+
if result.ok:
|
|
131
|
+
self._sync_models(
|
|
132
|
+
self._target,
|
|
133
|
+
self.query_one("#models", Input).value,
|
|
134
|
+
list((self._row or {}).get("models") or []),
|
|
135
|
+
)
|
|
136
|
+
else: # key
|
|
137
|
+
key = self.query_one("#api_key", Input).value.strip()
|
|
138
|
+
result = self.service.set_api_key(self._target, key, overwrite=True, yes=True)
|
|
139
|
+
self.notify(result.message, severity="error" if not result.ok else "information")
|
|
140
|
+
if result.ok:
|
|
141
|
+
self.dismiss(None)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from textual.widgets import Static
|
|
4
|
+
|
|
5
|
+
from xg.tui.state import TuiState
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class QueueStatus(Static):
|
|
9
|
+
"""Compact preview of submissions waiting behind the active turn."""
|
|
10
|
+
|
|
11
|
+
MAX_PREVIEW_ITEMS = 4
|
|
12
|
+
PREVIEW_LENGTH = 72
|
|
13
|
+
|
|
14
|
+
def update_state(self, state: TuiState) -> None:
|
|
15
|
+
if not state.queue:
|
|
16
|
+
self.update("")
|
|
17
|
+
self.display = False
|
|
18
|
+
return
|
|
19
|
+
|
|
20
|
+
lines = [f"队列 {len(state.queue)} 项"]
|
|
21
|
+
for item in state.queue[: self.MAX_PREVIEW_ITEMS]:
|
|
22
|
+
preview = " ".join(item.text.split())
|
|
23
|
+
if len(preview) > self.PREVIEW_LENGTH:
|
|
24
|
+
preview = preview[: self.PREVIEW_LENGTH - 1] + "…"
|
|
25
|
+
lines.append(f" #{item.id.removeprefix('queue-')} {preview}")
|
|
26
|
+
remaining = len(state.queue) - self.MAX_PREVIEW_ITEMS
|
|
27
|
+
if remaining > 0:
|
|
28
|
+
lines.append(f" 还有 {remaining} 项")
|
|
29
|
+
self.update("\n".join(lines))
|
|
30
|
+
self.display = True
|