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,94 @@
|
|
|
1
|
+
"""Inline action cards used for all TUI confirmations.
|
|
2
|
+
|
|
3
|
+
Cards are read-only text: context, risk and the input protocol. Decisions
|
|
4
|
+
are always typed into the main Composer, so no Button or inner Input lives
|
|
5
|
+
here.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
|
|
12
|
+
from textual.app import ComposeResult
|
|
13
|
+
from textual.containers import Vertical
|
|
14
|
+
from textual.widgets import Static
|
|
15
|
+
|
|
16
|
+
from xg.tui.state import ApprovalRequest, ConfirmationRequest
|
|
17
|
+
|
|
18
|
+
APPROVAL_HINT = "输入 y 批准 · a 全部放行 · r 拒绝 · s 跳过 · e 修改参数"
|
|
19
|
+
CONFIRMATION_HINTS = {
|
|
20
|
+
"init": "输入 y 确认写入 · n 取消",
|
|
21
|
+
"memory_clear": "输入 clear 确认清空 · 其他输入取消",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class InlineConfirmationCard(Vertical):
|
|
26
|
+
def __init__(self, request: ConfirmationRequest) -> None:
|
|
27
|
+
# Per-kind class lets the theme give each confirmation its own
|
|
28
|
+
# border color while sharing one simple layout. No fixed widget id:
|
|
29
|
+
# TranscriptView keeps this card stable and updates its content in
|
|
30
|
+
# place, so no nested input or duplicate child id is required.
|
|
31
|
+
super().__init__(
|
|
32
|
+
classes=f"inline-action-card inline-confirmation-card confirmation-{request.kind}",
|
|
33
|
+
)
|
|
34
|
+
self.request = request
|
|
35
|
+
# The Static is created eagerly: compose() runs asynchronously, so
|
|
36
|
+
# updates must not depend on the card being mounted already.
|
|
37
|
+
hint = CONFIRMATION_HINTS.get(self.request.kind, "输入 y 确认 · n 取消")
|
|
38
|
+
self._text = Static(f"{self.request.title}\n\n{self.request.body}\n\n{hint}")
|
|
39
|
+
|
|
40
|
+
def compose(self) -> ComposeResult:
|
|
41
|
+
yield self._text
|
|
42
|
+
|
|
43
|
+
def update_request(self, request: ConfirmationRequest) -> None:
|
|
44
|
+
"""Update the stable card without replacing its DOM node."""
|
|
45
|
+
self.request = request
|
|
46
|
+
hint = CONFIRMATION_HINTS.get(self.request.kind, "输入 y 确认 · n 取消")
|
|
47
|
+
self._text.update(f"{self.request.title}\n\n{self.request.body}\n\n{hint}")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class InlineApprovalCard(Vertical):
|
|
51
|
+
def __init__(self, request: ApprovalRequest) -> None:
|
|
52
|
+
super().__init__(classes="inline-action-card inline-approval-card")
|
|
53
|
+
self.request = request
|
|
54
|
+
self._mode = ""
|
|
55
|
+
self._modified_args: dict | None = None
|
|
56
|
+
self._text = Static(self._render_text(), classes="inline-approval-text")
|
|
57
|
+
|
|
58
|
+
def compose(self) -> ComposeResult:
|
|
59
|
+
yield self._text
|
|
60
|
+
|
|
61
|
+
def update_request(self, request: ApprovalRequest) -> None:
|
|
62
|
+
"""Update request data while preserving the card and its mode."""
|
|
63
|
+
self.request = request
|
|
64
|
+
self._text.update(self._render_text())
|
|
65
|
+
|
|
66
|
+
def set_mode(self, mode: str = "", modified_args: dict | None = None) -> None:
|
|
67
|
+
"""Switch the card text; empty mode means waiting for the decision."""
|
|
68
|
+
self._mode = mode
|
|
69
|
+
self._modified_args = modified_args
|
|
70
|
+
self._text.update(self._render_text())
|
|
71
|
+
|
|
72
|
+
def _render_text(self) -> str:
|
|
73
|
+
if self._mode == "approval_edit":
|
|
74
|
+
return (
|
|
75
|
+
f"需要人工审批:{self.request.tool_name}\n\n"
|
|
76
|
+
"请输入修改后的完整 JSON 参数。\n"
|
|
77
|
+
"修改后的参数仍会经过 PathGuard / CommandGuard 检查。\n"
|
|
78
|
+
"输入 Esc 取消修改。"
|
|
79
|
+
)
|
|
80
|
+
if self._mode == "approval_confirm_modified":
|
|
81
|
+
args = json.dumps(self._modified_args or {}, ensure_ascii=False, indent=2)
|
|
82
|
+
return (
|
|
83
|
+
f"需要人工审批:{self.request.tool_name}\n\n"
|
|
84
|
+
f"参数已修改为:\n{args}\n\n"
|
|
85
|
+
"输入 y 确认执行 · r 拒绝执行 · Esc 取消"
|
|
86
|
+
)
|
|
87
|
+
args = json.dumps(self.request.args, ensure_ascii=False, indent=2)
|
|
88
|
+
return (
|
|
89
|
+
"需要人工审批\n"
|
|
90
|
+
f"工具:{self.request.tool_name}\n"
|
|
91
|
+
f"敏感级别:{self.request.level}\n"
|
|
92
|
+
f"当前参数:\n{args}\n\n"
|
|
93
|
+
f"{APPROVAL_HINT}"
|
|
94
|
+
)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Collapsible conversation group for one Team AgentRun."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from textual.events import Click
|
|
6
|
+
from textual.widgets import Static
|
|
7
|
+
|
|
8
|
+
from xg.tui.messages import AgentGroupToggled
|
|
9
|
+
from xg.tui.renderables import agent_group_renderable
|
|
10
|
+
from xg.tui.state import AgentGroupState
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AgentGroupCard(Static):
|
|
14
|
+
"""One Agent conversation inside the shared TranscriptView."""
|
|
15
|
+
|
|
16
|
+
can_focus = True
|
|
17
|
+
BINDINGS = [
|
|
18
|
+
("enter", "toggle", "展开/折叠 Agent"),
|
|
19
|
+
("space", "toggle", "展开/折叠 Agent"),
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
def __init__(self, group: AgentGroupState) -> None:
|
|
23
|
+
self.group_id = group.group_id
|
|
24
|
+
self.group = group
|
|
25
|
+
super().__init__(agent_group_renderable(group))
|
|
26
|
+
|
|
27
|
+
def update_group(self, group: AgentGroupState) -> None:
|
|
28
|
+
self.group = group
|
|
29
|
+
self.update(agent_group_renderable(group))
|
|
30
|
+
|
|
31
|
+
def _toggle(self) -> None:
|
|
32
|
+
self.post_message(AgentGroupToggled(self.group_id))
|
|
33
|
+
|
|
34
|
+
def on_click(self, event: Click) -> None:
|
|
35
|
+
event.stop()
|
|
36
|
+
self._toggle()
|
|
37
|
+
|
|
38
|
+
def action_toggle(self) -> None:
|
|
39
|
+
self._toggle()
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
from textual.app import ComposeResult
|
|
6
|
+
from textual.containers import Vertical
|
|
7
|
+
from textual.screen import ModalScreen
|
|
8
|
+
from textual.widgets import Button, Input, Static
|
|
9
|
+
|
|
10
|
+
from xg.safety.hitl import ApprovalDecision
|
|
11
|
+
from xg.tui.state import ApprovalRequest
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ApprovalModal(ModalScreen[str]):
|
|
15
|
+
BINDINGS = [("escape", "reject", "拒绝"), ("enter", "approve", "批准"), ("y", "approve", "批准"), ("a", "allow_all", "全部放行"), ("r", "reject", "拒绝"), ("s", "skip", "跳过"), ("e", "edit", "修改参数")]
|
|
16
|
+
|
|
17
|
+
def __init__(self, request: ApprovalRequest) -> None:
|
|
18
|
+
super().__init__()
|
|
19
|
+
self.request = request
|
|
20
|
+
|
|
21
|
+
def compose(self) -> ComposeResult:
|
|
22
|
+
args = json.dumps(self.request.args, ensure_ascii=False, indent=2)
|
|
23
|
+
with Vertical(id="approval-dialog"):
|
|
24
|
+
yield Static(f"需要审批:{self.request.tool_name}\n敏感级别:{self.request.level}\n\n{args}")
|
|
25
|
+
yield Input(placeholder="按 e 修改 JSON 参数", id="approval-json")
|
|
26
|
+
yield Button("批准 (Enter/y)", id="approve", variant="success")
|
|
27
|
+
yield Button("本会话全部放行 (a)", id="allow-all")
|
|
28
|
+
yield Button("拒绝 (r/Esc)", id="reject", variant="error")
|
|
29
|
+
|
|
30
|
+
def _close(self, value: str) -> None:
|
|
31
|
+
self.dismiss(value)
|
|
32
|
+
|
|
33
|
+
def action_approve(self) -> None:
|
|
34
|
+
self._close("approve")
|
|
35
|
+
|
|
36
|
+
def action_allow_all(self) -> None:
|
|
37
|
+
self._close("allow_all")
|
|
38
|
+
|
|
39
|
+
def action_reject(self) -> None:
|
|
40
|
+
self._close("reject")
|
|
41
|
+
|
|
42
|
+
def action_skip(self) -> None:
|
|
43
|
+
self._close("skip")
|
|
44
|
+
|
|
45
|
+
def action_edit(self) -> None:
|
|
46
|
+
self.query_one("#approval-json", Input).focus()
|
|
47
|
+
|
|
48
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
49
|
+
if event.input.id != "approval-json":
|
|
50
|
+
return
|
|
51
|
+
try:
|
|
52
|
+
json.loads(event.value)
|
|
53
|
+
except json.JSONDecodeError:
|
|
54
|
+
event.input.value = ""
|
|
55
|
+
return
|
|
56
|
+
self._close("modify:" + event.value)
|
|
57
|
+
|
|
58
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
59
|
+
self._close(event.button.id or "reject")
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Interactive execution-trace card for the fullscreen TUI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from textual.events import Click
|
|
6
|
+
from textual.widgets import Static
|
|
7
|
+
|
|
8
|
+
from xg.tui.messages import TraceCardToggled
|
|
9
|
+
from xg.tui.renderables import trace_renderable
|
|
10
|
+
from xg.tui.state import TranscriptItem
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CollapsibleCard(Static):
|
|
14
|
+
"""State-driven card whose header/body can be toggled by mouse or key."""
|
|
15
|
+
|
|
16
|
+
can_focus = True
|
|
17
|
+
BINDINGS = [
|
|
18
|
+
("enter", "toggle", "展开/折叠"),
|
|
19
|
+
("space", "toggle", "展开/折叠"),
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
def __init__(self, item: TranscriptItem) -> None:
|
|
23
|
+
self.item_id = item.id
|
|
24
|
+
self.item = item
|
|
25
|
+
super().__init__(trace_renderable(item))
|
|
26
|
+
|
|
27
|
+
def update_item(self, item: TranscriptItem) -> None:
|
|
28
|
+
self.item = item
|
|
29
|
+
self.update(trace_renderable(item))
|
|
30
|
+
|
|
31
|
+
def _toggle(self) -> None:
|
|
32
|
+
self.post_message(TraceCardToggled(self.item_id))
|
|
33
|
+
|
|
34
|
+
def on_click(self, event: Click) -> None:
|
|
35
|
+
event.stop()
|
|
36
|
+
self._toggle()
|
|
37
|
+
|
|
38
|
+
def action_toggle(self) -> None:
|
|
39
|
+
self._toggle()
|
|
40
|
+
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Local slash-command suggestions for the Composer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from rich.text import Text
|
|
6
|
+
from textual.widgets import OptionList
|
|
7
|
+
|
|
8
|
+
from xg.cli.commands import SLASH_COMMANDS, SlashCommandSpec
|
|
9
|
+
from xg.cli.completion import (
|
|
10
|
+
CompletionCandidate,
|
|
11
|
+
CompletionProviderRegistry,
|
|
12
|
+
completion_candidates,
|
|
13
|
+
dynamic_candidates,
|
|
14
|
+
)
|
|
15
|
+
from xg.tui.messages import CommandSuggestionSelected
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class CommandSuggestions(OptionList):
|
|
19
|
+
"""A non-focusable, mouse-selectable list above the Composer.
|
|
20
|
+
|
|
21
|
+
The list shows layered candidates (command / subcommand / static option /
|
|
22
|
+
dynamic value) in front of a thin horizontal rule... The displayed text
|
|
23
|
+
keeps the candidate's insert_text separate, so help text never leaks into
|
|
24
|
+
the value.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
can_focus = False
|
|
28
|
+
|
|
29
|
+
def __init__(self) -> None:
|
|
30
|
+
super().__init__(id="command-suggestions", markup=False)
|
|
31
|
+
self._visible_candidates: list[CompletionCandidate] = []
|
|
32
|
+
self._revision = 0
|
|
33
|
+
self._registry: CompletionProviderRegistry | None = None
|
|
34
|
+
self.display = False
|
|
35
|
+
|
|
36
|
+
def set_dynamic_registry(self, registry: CompletionProviderRegistry | None) -> None:
|
|
37
|
+
"""Attach the runtime provider registry for dynamic value candidates."""
|
|
38
|
+
self._registry = registry
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def visible_candidates(self) -> list[CompletionCandidate]:
|
|
42
|
+
return self._visible_candidates
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def visible_specs(self) -> tuple[SlashCommandSpec, ...]:
|
|
46
|
+
"""Top-level command specs (backward-compatible accessor).
|
|
47
|
+
|
|
48
|
+
Only meaningful while the candidate list is a pure command list; empty
|
|
49
|
+
otherwise. New callers should use ``visible_candidates`` instead.
|
|
50
|
+
"""
|
|
51
|
+
return tuple(
|
|
52
|
+
spec
|
|
53
|
+
for cand in self._visible_candidates
|
|
54
|
+
if cand.kind == "command"
|
|
55
|
+
for spec in SLASH_COMMANDS
|
|
56
|
+
if spec.name == cand.insert_text
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def is_open(self) -> bool:
|
|
61
|
+
return self.display and bool(self._visible_candidates)
|
|
62
|
+
|
|
63
|
+
def update_query(self, query: str, cursor_position: int | None = None) -> None:
|
|
64
|
+
"""Refresh layered candidates (static + dynamic) for the input line.
|
|
65
|
+
|
|
66
|
+
Static candidates come first, then dynamic value candidates (the
|
|
67
|
+
providers are synchronous and read only in-memory agent state), then
|
|
68
|
+
the list is re-rendered. Dynamic updates use a per-query revision so
|
|
69
|
+
a stale refresh from an older input never clobbers a newer one.
|
|
70
|
+
"""
|
|
71
|
+
revision = self._revision + 1
|
|
72
|
+
self._revision = revision
|
|
73
|
+
static = completion_candidates(query, cursor_position)
|
|
74
|
+
dynamic = (
|
|
75
|
+
dynamic_candidates(query, cursor_position, self._registry)
|
|
76
|
+
if self._registry is not None
|
|
77
|
+
else []
|
|
78
|
+
)
|
|
79
|
+
candidates = list(static) + dynamic
|
|
80
|
+
# Defensive: if a newer update_query() bumped the revision before we
|
|
81
|
+
# applied this result, drop it. Synchronous path never races in
|
|
82
|
+
# practice, but the guard keeps a stale refresh from winning.
|
|
83
|
+
if self._revision != revision:
|
|
84
|
+
return
|
|
85
|
+
self._visible_candidates = candidates
|
|
86
|
+
if not candidates:
|
|
87
|
+
self.set_options(())
|
|
88
|
+
self.display = False
|
|
89
|
+
return
|
|
90
|
+
|
|
91
|
+
self.set_options(self._render_candidate(cand) for cand in candidates)
|
|
92
|
+
self.highlighted = 0
|
|
93
|
+
self.display = True
|
|
94
|
+
|
|
95
|
+
def close(self) -> None:
|
|
96
|
+
"""Hide suggestions without changing the Composer value."""
|
|
97
|
+
|
|
98
|
+
self.display = False
|
|
99
|
+
|
|
100
|
+
def move_selection(self, direction: int) -> None:
|
|
101
|
+
if not self._visible_candidates:
|
|
102
|
+
return
|
|
103
|
+
current = self.highlighted if self.highlighted is not None else 0
|
|
104
|
+
self.highlighted = (current + direction) % len(self._visible_candidates)
|
|
105
|
+
self.scroll_to_highlight()
|
|
106
|
+
|
|
107
|
+
def selected_candidate(self) -> CompletionCandidate | None:
|
|
108
|
+
if self.highlighted is None or not self._visible_candidates:
|
|
109
|
+
return None
|
|
110
|
+
return self._visible_candidates[self.highlighted]
|
|
111
|
+
|
|
112
|
+
@staticmethod
|
|
113
|
+
def _render_candidate(candidate: CompletionCandidate) -> Text:
|
|
114
|
+
text = Text()
|
|
115
|
+
text.append(candidate.label, style="bold")
|
|
116
|
+
if candidate.detail:
|
|
117
|
+
text.append(" ")
|
|
118
|
+
text.append(candidate.detail, style="dim")
|
|
119
|
+
return text
|
|
120
|
+
|
|
121
|
+
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
|
|
122
|
+
"""Turn a mouse click into a Composer fill operation."""
|
|
123
|
+
|
|
124
|
+
if 0 <= event.option_index < len(self._visible_candidates):
|
|
125
|
+
event.stop()
|
|
126
|
+
self.post_message(
|
|
127
|
+
CommandSuggestionSelected(self._visible_candidates[event.option_index])
|
|
128
|
+
)
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
|
|
5
|
+
from textual.events import Key
|
|
6
|
+
from textual.widgets import Input
|
|
7
|
+
|
|
8
|
+
from xg.input_history import InputHistory
|
|
9
|
+
from xg.tui.widgets.command_suggestions import CommandSuggestions
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Composer(Input):
|
|
13
|
+
"""Single-line Composer with bounded XG input history navigation."""
|
|
14
|
+
|
|
15
|
+
BINDINGS = [("escape", "escape_input", "清除输入或取消当前操作")]
|
|
16
|
+
|
|
17
|
+
def __init__(self) -> None:
|
|
18
|
+
super().__init__(placeholder="输入任务或 /help …", id="composer")
|
|
19
|
+
self.suggestions_enabled = True
|
|
20
|
+
self.input_history: InputHistory | None = None
|
|
21
|
+
|
|
22
|
+
def set_input_history(self, history: InputHistory | None) -> None:
|
|
23
|
+
self.input_history = history
|
|
24
|
+
|
|
25
|
+
def record_submission(self, text: str, accepted: bool) -> None:
|
|
26
|
+
if self.input_history is None:
|
|
27
|
+
return
|
|
28
|
+
if accepted:
|
|
29
|
+
self.input_history.add(text)
|
|
30
|
+
else:
|
|
31
|
+
self.input_history.reset_cursor()
|
|
32
|
+
|
|
33
|
+
def reset_history_cursor(self) -> None:
|
|
34
|
+
if self.input_history is not None:
|
|
35
|
+
self.input_history.reset_cursor()
|
|
36
|
+
|
|
37
|
+
def _history_blocked(self, app) -> bool:
|
|
38
|
+
if getattr(app, "_replan_mode", False):
|
|
39
|
+
return True
|
|
40
|
+
controller = getattr(app, "controller", None)
|
|
41
|
+
state = getattr(controller, "state", None)
|
|
42
|
+
return bool(
|
|
43
|
+
getattr(app, "_has_pending_plan", lambda: False)()
|
|
44
|
+
or getattr(state, "pending_approval", None) is not None
|
|
45
|
+
or getattr(state, "pending_confirmation", None) is not None
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
def _navigate_history(self, direction: str) -> None:
|
|
49
|
+
if self.input_history is None:
|
|
50
|
+
return
|
|
51
|
+
suggestions = self._suggestions()
|
|
52
|
+
if suggestions is not None:
|
|
53
|
+
suggestions.close()
|
|
54
|
+
value = (
|
|
55
|
+
self.input_history.previous(self.value)
|
|
56
|
+
if direction == "previous"
|
|
57
|
+
else self.input_history.next()
|
|
58
|
+
)
|
|
59
|
+
self.value = value
|
|
60
|
+
self.cursor_position = len(value)
|
|
61
|
+
self.focus()
|
|
62
|
+
|
|
63
|
+
def set_suggestions_enabled(self, enabled: bool) -> None:
|
|
64
|
+
self.suggestions_enabled = enabled
|
|
65
|
+
|
|
66
|
+
def _suggestions(self) -> CommandSuggestions | None:
|
|
67
|
+
try:
|
|
68
|
+
return self.app.query_one("#command-suggestions", CommandSuggestions)
|
|
69
|
+
except Exception:
|
|
70
|
+
# Input can receive an event while the app is mounting or
|
|
71
|
+
# unmounting. In that short window there is no suggestion widget.
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
def on_input_changed(self, event: Input.Changed) -> None:
|
|
75
|
+
"""Keep filtering local and leave submission to the App."""
|
|
76
|
+
if event.input is not self or not self.suggestions_enabled:
|
|
77
|
+
return
|
|
78
|
+
suggestions = self._suggestions()
|
|
79
|
+
if suggestions is not None:
|
|
80
|
+
suggestions.update_query(event.value, self.cursor_position)
|
|
81
|
+
|
|
82
|
+
def on_key(self, event: Key) -> None:
|
|
83
|
+
"""Keep plan-review shortcuts working while the input stays enabled."""
|
|
84
|
+
app = self.app
|
|
85
|
+
suggestions = self._suggestions()
|
|
86
|
+
if suggestions is not None and suggestions.is_open:
|
|
87
|
+
if event.key == "up":
|
|
88
|
+
event.prevent_default()
|
|
89
|
+
event.stop()
|
|
90
|
+
suggestions.move_selection(-1)
|
|
91
|
+
return
|
|
92
|
+
if event.key == "down":
|
|
93
|
+
event.prevent_default()
|
|
94
|
+
event.stop()
|
|
95
|
+
suggestions.move_selection(1)
|
|
96
|
+
return
|
|
97
|
+
if event.key == "tab":
|
|
98
|
+
candidate = suggestions.selected_candidate()
|
|
99
|
+
if candidate is not None:
|
|
100
|
+
event.prevent_default()
|
|
101
|
+
event.stop()
|
|
102
|
+
app.complete_command_suggestion(candidate)
|
|
103
|
+
return
|
|
104
|
+
if event.key == "escape":
|
|
105
|
+
event.prevent_default()
|
|
106
|
+
event.stop()
|
|
107
|
+
suggestions.close()
|
|
108
|
+
return
|
|
109
|
+
|
|
110
|
+
has_pending_plan = getattr(app, "_has_pending_plan", lambda: False)()
|
|
111
|
+
if not self._history_blocked(app) and event.key == "up":
|
|
112
|
+
event.prevent_default()
|
|
113
|
+
event.stop()
|
|
114
|
+
self._navigate_history("previous")
|
|
115
|
+
return
|
|
116
|
+
if not self._history_blocked(app) and event.key == "down":
|
|
117
|
+
event.prevent_default()
|
|
118
|
+
event.stop()
|
|
119
|
+
self._navigate_history("next")
|
|
120
|
+
return
|
|
121
|
+
if not has_pending_plan or getattr(app, "_replan_mode", False):
|
|
122
|
+
if (
|
|
123
|
+
event.key == "d"
|
|
124
|
+
and not self.value
|
|
125
|
+
and getattr(app, "_has_detail_toggle_target", lambda: False)()
|
|
126
|
+
):
|
|
127
|
+
event.prevent_default()
|
|
128
|
+
event.stop()
|
|
129
|
+
app.action_plan_details()
|
|
130
|
+
return
|
|
131
|
+
if event.key == "r":
|
|
132
|
+
event.prevent_default()
|
|
133
|
+
event.stop()
|
|
134
|
+
app.action_plan_replan()
|
|
135
|
+
elif event.key == "d":
|
|
136
|
+
event.prevent_default()
|
|
137
|
+
event.stop()
|
|
138
|
+
app.action_plan_details()
|
|
139
|
+
elif event.key == "enter":
|
|
140
|
+
event.prevent_default()
|
|
141
|
+
event.stop()
|
|
142
|
+
app.action_plan_execute()
|
|
143
|
+
elif event.key == "escape":
|
|
144
|
+
event.prevent_default()
|
|
145
|
+
event.stop()
|
|
146
|
+
asyncio.create_task(app.action_escape())
|
|
147
|
+
|
|
148
|
+
async def action_escape_input(self) -> None:
|
|
149
|
+
# Input consumes Escape before it bubbles to the App. Delegate to the
|
|
150
|
+
# app so idle, running, plan-review and replan states share one policy.
|
|
151
|
+
await self.app.action_escape()
|