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
@@ -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