fast-agent-mcp 0.4.7__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.
- fast_agent/__init__.py +183 -0
- fast_agent/acp/__init__.py +19 -0
- fast_agent/acp/acp_aware_mixin.py +304 -0
- fast_agent/acp/acp_context.py +437 -0
- fast_agent/acp/content_conversion.py +136 -0
- fast_agent/acp/filesystem_runtime.py +427 -0
- fast_agent/acp/permission_store.py +269 -0
- fast_agent/acp/server/__init__.py +5 -0
- fast_agent/acp/server/agent_acp_server.py +1472 -0
- fast_agent/acp/slash_commands.py +1050 -0
- fast_agent/acp/terminal_runtime.py +408 -0
- fast_agent/acp/tool_permission_adapter.py +125 -0
- fast_agent/acp/tool_permissions.py +474 -0
- fast_agent/acp/tool_progress.py +814 -0
- fast_agent/agents/__init__.py +85 -0
- fast_agent/agents/agent_types.py +64 -0
- fast_agent/agents/llm_agent.py +350 -0
- fast_agent/agents/llm_decorator.py +1139 -0
- fast_agent/agents/mcp_agent.py +1337 -0
- fast_agent/agents/tool_agent.py +271 -0
- fast_agent/agents/workflow/agents_as_tools_agent.py +849 -0
- fast_agent/agents/workflow/chain_agent.py +212 -0
- fast_agent/agents/workflow/evaluator_optimizer.py +380 -0
- fast_agent/agents/workflow/iterative_planner.py +652 -0
- fast_agent/agents/workflow/maker_agent.py +379 -0
- fast_agent/agents/workflow/orchestrator_models.py +218 -0
- fast_agent/agents/workflow/orchestrator_prompts.py +248 -0
- fast_agent/agents/workflow/parallel_agent.py +250 -0
- fast_agent/agents/workflow/router_agent.py +353 -0
- fast_agent/cli/__init__.py +0 -0
- fast_agent/cli/__main__.py +73 -0
- fast_agent/cli/commands/acp.py +159 -0
- fast_agent/cli/commands/auth.py +404 -0
- fast_agent/cli/commands/check_config.py +783 -0
- fast_agent/cli/commands/go.py +514 -0
- fast_agent/cli/commands/quickstart.py +557 -0
- fast_agent/cli/commands/serve.py +143 -0
- fast_agent/cli/commands/server_helpers.py +114 -0
- fast_agent/cli/commands/setup.py +174 -0
- fast_agent/cli/commands/url_parser.py +190 -0
- fast_agent/cli/constants.py +40 -0
- fast_agent/cli/main.py +115 -0
- fast_agent/cli/terminal.py +24 -0
- fast_agent/config.py +798 -0
- fast_agent/constants.py +41 -0
- fast_agent/context.py +279 -0
- fast_agent/context_dependent.py +50 -0
- fast_agent/core/__init__.py +92 -0
- fast_agent/core/agent_app.py +448 -0
- fast_agent/core/core_app.py +137 -0
- fast_agent/core/direct_decorators.py +784 -0
- fast_agent/core/direct_factory.py +620 -0
- fast_agent/core/error_handling.py +27 -0
- fast_agent/core/exceptions.py +90 -0
- fast_agent/core/executor/__init__.py +0 -0
- fast_agent/core/executor/executor.py +280 -0
- fast_agent/core/executor/task_registry.py +32 -0
- fast_agent/core/executor/workflow_signal.py +324 -0
- fast_agent/core/fastagent.py +1186 -0
- fast_agent/core/logging/__init__.py +5 -0
- fast_agent/core/logging/events.py +138 -0
- fast_agent/core/logging/json_serializer.py +164 -0
- fast_agent/core/logging/listeners.py +309 -0
- fast_agent/core/logging/logger.py +278 -0
- fast_agent/core/logging/transport.py +481 -0
- fast_agent/core/prompt.py +9 -0
- fast_agent/core/prompt_templates.py +183 -0
- fast_agent/core/validation.py +326 -0
- fast_agent/event_progress.py +62 -0
- fast_agent/history/history_exporter.py +49 -0
- fast_agent/human_input/__init__.py +47 -0
- fast_agent/human_input/elicitation_handler.py +123 -0
- fast_agent/human_input/elicitation_state.py +33 -0
- fast_agent/human_input/form_elements.py +59 -0
- fast_agent/human_input/form_fields.py +256 -0
- fast_agent/human_input/simple_form.py +113 -0
- fast_agent/human_input/types.py +40 -0
- fast_agent/interfaces.py +310 -0
- fast_agent/llm/__init__.py +9 -0
- fast_agent/llm/cancellation.py +22 -0
- fast_agent/llm/fastagent_llm.py +931 -0
- fast_agent/llm/internal/passthrough.py +161 -0
- fast_agent/llm/internal/playback.py +129 -0
- fast_agent/llm/internal/silent.py +41 -0
- fast_agent/llm/internal/slow.py +38 -0
- fast_agent/llm/memory.py +275 -0
- fast_agent/llm/model_database.py +490 -0
- fast_agent/llm/model_factory.py +388 -0
- fast_agent/llm/model_info.py +102 -0
- fast_agent/llm/prompt_utils.py +155 -0
- fast_agent/llm/provider/anthropic/anthropic_utils.py +84 -0
- fast_agent/llm/provider/anthropic/cache_planner.py +56 -0
- fast_agent/llm/provider/anthropic/llm_anthropic.py +796 -0
- fast_agent/llm/provider/anthropic/multipart_converter_anthropic.py +462 -0
- fast_agent/llm/provider/bedrock/bedrock_utils.py +218 -0
- fast_agent/llm/provider/bedrock/llm_bedrock.py +2207 -0
- fast_agent/llm/provider/bedrock/multipart_converter_bedrock.py +84 -0
- fast_agent/llm/provider/google/google_converter.py +466 -0
- fast_agent/llm/provider/google/llm_google_native.py +681 -0
- fast_agent/llm/provider/openai/llm_aliyun.py +31 -0
- fast_agent/llm/provider/openai/llm_azure.py +143 -0
- fast_agent/llm/provider/openai/llm_deepseek.py +76 -0
- fast_agent/llm/provider/openai/llm_generic.py +35 -0
- fast_agent/llm/provider/openai/llm_google_oai.py +32 -0
- fast_agent/llm/provider/openai/llm_groq.py +42 -0
- fast_agent/llm/provider/openai/llm_huggingface.py +85 -0
- fast_agent/llm/provider/openai/llm_openai.py +1195 -0
- fast_agent/llm/provider/openai/llm_openai_compatible.py +138 -0
- fast_agent/llm/provider/openai/llm_openrouter.py +45 -0
- fast_agent/llm/provider/openai/llm_tensorzero_openai.py +128 -0
- fast_agent/llm/provider/openai/llm_xai.py +38 -0
- fast_agent/llm/provider/openai/multipart_converter_openai.py +561 -0
- fast_agent/llm/provider/openai/openai_multipart.py +169 -0
- fast_agent/llm/provider/openai/openai_utils.py +67 -0
- fast_agent/llm/provider/openai/responses.py +133 -0
- fast_agent/llm/provider_key_manager.py +139 -0
- fast_agent/llm/provider_types.py +34 -0
- fast_agent/llm/request_params.py +61 -0
- fast_agent/llm/sampling_converter.py +98 -0
- fast_agent/llm/stream_types.py +9 -0
- fast_agent/llm/usage_tracking.py +445 -0
- fast_agent/mcp/__init__.py +56 -0
- fast_agent/mcp/common.py +26 -0
- fast_agent/mcp/elicitation_factory.py +84 -0
- fast_agent/mcp/elicitation_handlers.py +164 -0
- fast_agent/mcp/gen_client.py +83 -0
- fast_agent/mcp/helpers/__init__.py +36 -0
- fast_agent/mcp/helpers/content_helpers.py +352 -0
- fast_agent/mcp/helpers/server_config_helpers.py +25 -0
- fast_agent/mcp/hf_auth.py +147 -0
- fast_agent/mcp/interfaces.py +92 -0
- fast_agent/mcp/logger_textio.py +108 -0
- fast_agent/mcp/mcp_agent_client_session.py +411 -0
- fast_agent/mcp/mcp_aggregator.py +2175 -0
- fast_agent/mcp/mcp_connection_manager.py +723 -0
- fast_agent/mcp/mcp_content.py +262 -0
- fast_agent/mcp/mime_utils.py +108 -0
- fast_agent/mcp/oauth_client.py +509 -0
- fast_agent/mcp/prompt.py +159 -0
- fast_agent/mcp/prompt_message_extended.py +155 -0
- fast_agent/mcp/prompt_render.py +84 -0
- fast_agent/mcp/prompt_serialization.py +580 -0
- fast_agent/mcp/prompts/__init__.py +0 -0
- fast_agent/mcp/prompts/__main__.py +7 -0
- fast_agent/mcp/prompts/prompt_constants.py +18 -0
- fast_agent/mcp/prompts/prompt_helpers.py +238 -0
- fast_agent/mcp/prompts/prompt_load.py +186 -0
- fast_agent/mcp/prompts/prompt_server.py +552 -0
- fast_agent/mcp/prompts/prompt_template.py +438 -0
- fast_agent/mcp/resource_utils.py +215 -0
- fast_agent/mcp/sampling.py +200 -0
- fast_agent/mcp/server/__init__.py +4 -0
- fast_agent/mcp/server/agent_server.py +613 -0
- fast_agent/mcp/skybridge.py +44 -0
- fast_agent/mcp/sse_tracking.py +287 -0
- fast_agent/mcp/stdio_tracking_simple.py +59 -0
- fast_agent/mcp/streamable_http_tracking.py +309 -0
- fast_agent/mcp/tool_execution_handler.py +137 -0
- fast_agent/mcp/tool_permission_handler.py +88 -0
- fast_agent/mcp/transport_tracking.py +634 -0
- fast_agent/mcp/types.py +24 -0
- fast_agent/mcp/ui_agent.py +48 -0
- fast_agent/mcp/ui_mixin.py +209 -0
- fast_agent/mcp_server_registry.py +89 -0
- fast_agent/py.typed +0 -0
- fast_agent/resources/examples/data-analysis/analysis-campaign.py +189 -0
- fast_agent/resources/examples/data-analysis/analysis.py +68 -0
- fast_agent/resources/examples/data-analysis/fastagent.config.yaml +41 -0
- fast_agent/resources/examples/data-analysis/mount-point/WA_Fn-UseC_-HR-Employee-Attrition.csv +1471 -0
- fast_agent/resources/examples/mcp/elicitations/elicitation_account_server.py +88 -0
- fast_agent/resources/examples/mcp/elicitations/elicitation_forms_server.py +297 -0
- fast_agent/resources/examples/mcp/elicitations/elicitation_game_server.py +164 -0
- fast_agent/resources/examples/mcp/elicitations/fastagent.config.yaml +35 -0
- fast_agent/resources/examples/mcp/elicitations/fastagent.secrets.yaml.example +17 -0
- fast_agent/resources/examples/mcp/elicitations/forms_demo.py +107 -0
- fast_agent/resources/examples/mcp/elicitations/game_character.py +65 -0
- fast_agent/resources/examples/mcp/elicitations/game_character_handler.py +256 -0
- fast_agent/resources/examples/mcp/elicitations/tool_call.py +21 -0
- fast_agent/resources/examples/mcp/state-transfer/agent_one.py +18 -0
- fast_agent/resources/examples/mcp/state-transfer/agent_two.py +18 -0
- fast_agent/resources/examples/mcp/state-transfer/fastagent.config.yaml +27 -0
- fast_agent/resources/examples/mcp/state-transfer/fastagent.secrets.yaml.example +15 -0
- fast_agent/resources/examples/researcher/fastagent.config.yaml +61 -0
- fast_agent/resources/examples/researcher/researcher-eval.py +53 -0
- fast_agent/resources/examples/researcher/researcher-imp.py +189 -0
- fast_agent/resources/examples/researcher/researcher.py +36 -0
- fast_agent/resources/examples/tensorzero/.env.sample +2 -0
- fast_agent/resources/examples/tensorzero/Makefile +31 -0
- fast_agent/resources/examples/tensorzero/README.md +56 -0
- fast_agent/resources/examples/tensorzero/agent.py +35 -0
- fast_agent/resources/examples/tensorzero/demo_images/clam.jpg +0 -0
- fast_agent/resources/examples/tensorzero/demo_images/crab.png +0 -0
- fast_agent/resources/examples/tensorzero/demo_images/shrimp.png +0 -0
- fast_agent/resources/examples/tensorzero/docker-compose.yml +105 -0
- fast_agent/resources/examples/tensorzero/fastagent.config.yaml +19 -0
- fast_agent/resources/examples/tensorzero/image_demo.py +67 -0
- fast_agent/resources/examples/tensorzero/mcp_server/Dockerfile +25 -0
- fast_agent/resources/examples/tensorzero/mcp_server/entrypoint.sh +35 -0
- fast_agent/resources/examples/tensorzero/mcp_server/mcp_server.py +31 -0
- fast_agent/resources/examples/tensorzero/mcp_server/pyproject.toml +11 -0
- fast_agent/resources/examples/tensorzero/simple_agent.py +25 -0
- fast_agent/resources/examples/tensorzero/tensorzero_config/system_schema.json +29 -0
- fast_agent/resources/examples/tensorzero/tensorzero_config/system_template.minijinja +11 -0
- fast_agent/resources/examples/tensorzero/tensorzero_config/tensorzero.toml +35 -0
- fast_agent/resources/examples/workflows/agents_as_tools_extended.py +73 -0
- fast_agent/resources/examples/workflows/agents_as_tools_simple.py +50 -0
- fast_agent/resources/examples/workflows/chaining.py +37 -0
- fast_agent/resources/examples/workflows/evaluator.py +77 -0
- fast_agent/resources/examples/workflows/fastagent.config.yaml +26 -0
- fast_agent/resources/examples/workflows/graded_report.md +89 -0
- fast_agent/resources/examples/workflows/human_input.py +28 -0
- fast_agent/resources/examples/workflows/maker.py +156 -0
- fast_agent/resources/examples/workflows/orchestrator.py +70 -0
- fast_agent/resources/examples/workflows/parallel.py +56 -0
- fast_agent/resources/examples/workflows/router.py +69 -0
- fast_agent/resources/examples/workflows/short_story.md +13 -0
- fast_agent/resources/examples/workflows/short_story.txt +19 -0
- fast_agent/resources/setup/.gitignore +30 -0
- fast_agent/resources/setup/agent.py +28 -0
- fast_agent/resources/setup/fastagent.config.yaml +65 -0
- fast_agent/resources/setup/fastagent.secrets.yaml.example +38 -0
- fast_agent/resources/setup/pyproject.toml.tmpl +23 -0
- fast_agent/skills/__init__.py +9 -0
- fast_agent/skills/registry.py +235 -0
- fast_agent/tools/elicitation.py +369 -0
- fast_agent/tools/shell_runtime.py +402 -0
- fast_agent/types/__init__.py +59 -0
- fast_agent/types/conversation_summary.py +294 -0
- fast_agent/types/llm_stop_reason.py +78 -0
- fast_agent/types/message_search.py +249 -0
- fast_agent/ui/__init__.py +38 -0
- fast_agent/ui/console.py +59 -0
- fast_agent/ui/console_display.py +1080 -0
- fast_agent/ui/elicitation_form.py +946 -0
- fast_agent/ui/elicitation_style.py +59 -0
- fast_agent/ui/enhanced_prompt.py +1400 -0
- fast_agent/ui/history_display.py +734 -0
- fast_agent/ui/interactive_prompt.py +1199 -0
- fast_agent/ui/markdown_helpers.py +104 -0
- fast_agent/ui/markdown_truncator.py +1004 -0
- fast_agent/ui/mcp_display.py +857 -0
- fast_agent/ui/mcp_ui_utils.py +235 -0
- fast_agent/ui/mermaid_utils.py +169 -0
- fast_agent/ui/message_primitives.py +50 -0
- fast_agent/ui/notification_tracker.py +205 -0
- fast_agent/ui/plain_text_truncator.py +68 -0
- fast_agent/ui/progress_display.py +10 -0
- fast_agent/ui/rich_progress.py +195 -0
- fast_agent/ui/streaming.py +774 -0
- fast_agent/ui/streaming_buffer.py +449 -0
- fast_agent/ui/tool_display.py +422 -0
- fast_agent/ui/usage_display.py +204 -0
- fast_agent/utils/__init__.py +5 -0
- fast_agent/utils/reasoning_stream_parser.py +77 -0
- fast_agent/utils/time.py +22 -0
- fast_agent/workflow_telemetry.py +261 -0
- fast_agent_mcp-0.4.7.dist-info/METADATA +788 -0
- fast_agent_mcp-0.4.7.dist-info/RECORD +261 -0
- fast_agent_mcp-0.4.7.dist-info/WHEEL +4 -0
- fast_agent_mcp-0.4.7.dist-info/entry_points.txt +7 -0
- fast_agent_mcp-0.4.7.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,946 @@
|
|
|
1
|
+
"""Simplified, robust elicitation form dialog."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from datetime import date, datetime
|
|
5
|
+
from typing import Any, Tuple
|
|
6
|
+
|
|
7
|
+
from mcp.types import ElicitRequestedSchema
|
|
8
|
+
from prompt_toolkit import Application
|
|
9
|
+
from prompt_toolkit.buffer import Buffer
|
|
10
|
+
from prompt_toolkit.filters import Condition
|
|
11
|
+
from prompt_toolkit.formatted_text import FormattedText
|
|
12
|
+
from prompt_toolkit.key_binding import KeyBindings
|
|
13
|
+
from prompt_toolkit.key_binding.bindings.focus import focus_next, focus_previous
|
|
14
|
+
from prompt_toolkit.layout import HSplit, Layout, ScrollablePane, VSplit, Window
|
|
15
|
+
from prompt_toolkit.layout.controls import BufferControl, FormattedTextControl
|
|
16
|
+
from prompt_toolkit.validation import ValidationError, Validator
|
|
17
|
+
from prompt_toolkit.widgets import (
|
|
18
|
+
Button,
|
|
19
|
+
Checkbox,
|
|
20
|
+
Frame,
|
|
21
|
+
Label,
|
|
22
|
+
RadioList,
|
|
23
|
+
)
|
|
24
|
+
from pydantic import AnyUrl, EmailStr
|
|
25
|
+
from pydantic import ValidationError as PydanticValidationError
|
|
26
|
+
|
|
27
|
+
from fast_agent.human_input.form_elements import ValidatedCheckboxList
|
|
28
|
+
from fast_agent.ui.elicitation_style import ELICITATION_STYLE
|
|
29
|
+
|
|
30
|
+
text_navigation_mode = False
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class SimpleNumberValidator(Validator):
|
|
34
|
+
"""Simple number validator with real-time feedback."""
|
|
35
|
+
|
|
36
|
+
def __init__(self, field_type: str, minimum: float | None = None, maximum: float | None = None):
|
|
37
|
+
self.field_type = field_type
|
|
38
|
+
self.minimum = minimum
|
|
39
|
+
self.maximum = maximum
|
|
40
|
+
|
|
41
|
+
def validate(self, document):
|
|
42
|
+
text = document.text.strip()
|
|
43
|
+
if not text:
|
|
44
|
+
return # Empty is OK for optional fields
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
if self.field_type == "integer":
|
|
48
|
+
value = int(text)
|
|
49
|
+
else:
|
|
50
|
+
value = float(text)
|
|
51
|
+
|
|
52
|
+
if self.minimum is not None and value < self.minimum:
|
|
53
|
+
raise ValidationError(
|
|
54
|
+
message=f"Must be ≥ {self.minimum}", cursor_position=len(text)
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
if self.maximum is not None and value > self.maximum:
|
|
58
|
+
raise ValidationError(
|
|
59
|
+
message=f"Must be ≤ {self.maximum}", cursor_position=len(text)
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
except ValueError:
|
|
63
|
+
raise ValidationError(message=f"Invalid {self.field_type}", cursor_position=len(text))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class SimpleStringValidator(Validator):
|
|
67
|
+
"""Simple string validator with real-time feedback."""
|
|
68
|
+
|
|
69
|
+
def __init__(
|
|
70
|
+
self,
|
|
71
|
+
min_length: int | None = None,
|
|
72
|
+
max_length: int | None = None,
|
|
73
|
+
pattern: str | None = None,
|
|
74
|
+
):
|
|
75
|
+
self.min_length = min_length
|
|
76
|
+
self.max_length = max_length
|
|
77
|
+
self.pattern = re.compile(pattern, re.DOTALL) if pattern else None
|
|
78
|
+
|
|
79
|
+
def validate(self, document):
|
|
80
|
+
text = document.text
|
|
81
|
+
if not text:
|
|
82
|
+
return # Empty is OK for optional fields
|
|
83
|
+
|
|
84
|
+
if self.min_length is not None and len(text) < self.min_length:
|
|
85
|
+
raise ValidationError(
|
|
86
|
+
message=f"Need {self.min_length - len(text)} more chars", cursor_position=len(text)
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
if self.max_length is not None and len(text) > self.max_length:
|
|
90
|
+
raise ValidationError(
|
|
91
|
+
message=f"Too long by {len(text) - self.max_length} chars",
|
|
92
|
+
cursor_position=self.max_length,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
if self.pattern is not None and self.pattern.fullmatch(text) is None:
|
|
96
|
+
# TODO: Wrap or truncate line if too long
|
|
97
|
+
raise ValidationError(
|
|
98
|
+
message=f"Must match pattern '{self.pattern.pattern}'", cursor_position=len(text)
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class FormatValidator(Validator):
|
|
103
|
+
"""Format-specific validator using Pydantic validators."""
|
|
104
|
+
|
|
105
|
+
def __init__(self, format_type: str):
|
|
106
|
+
self.format_type = format_type
|
|
107
|
+
|
|
108
|
+
def validate(self, document):
|
|
109
|
+
text = document.text.strip()
|
|
110
|
+
if not text:
|
|
111
|
+
return # Empty is OK for optional fields
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
if self.format_type == "email":
|
|
115
|
+
# Use Pydantic model validation for email
|
|
116
|
+
from pydantic import BaseModel
|
|
117
|
+
|
|
118
|
+
class EmailModel(BaseModel):
|
|
119
|
+
email: EmailStr
|
|
120
|
+
|
|
121
|
+
EmailModel(email=text)
|
|
122
|
+
elif self.format_type == "uri":
|
|
123
|
+
# Use Pydantic model validation for URI
|
|
124
|
+
from pydantic import BaseModel
|
|
125
|
+
|
|
126
|
+
class UriModel(BaseModel):
|
|
127
|
+
uri: AnyUrl
|
|
128
|
+
|
|
129
|
+
UriModel(uri=text)
|
|
130
|
+
elif self.format_type == "date":
|
|
131
|
+
# Validate ISO date format (YYYY-MM-DD)
|
|
132
|
+
date.fromisoformat(text)
|
|
133
|
+
elif self.format_type == "date-time":
|
|
134
|
+
# Validate ISO datetime format
|
|
135
|
+
datetime.fromisoformat(text.replace("Z", "+00:00"))
|
|
136
|
+
except (PydanticValidationError, ValueError):
|
|
137
|
+
# Extract readable error message
|
|
138
|
+
if self.format_type == "email":
|
|
139
|
+
message = "Invalid email format"
|
|
140
|
+
elif self.format_type == "uri":
|
|
141
|
+
message = "Invalid URI format"
|
|
142
|
+
elif self.format_type == "date":
|
|
143
|
+
message = "Invalid date (use YYYY-MM-DD)"
|
|
144
|
+
elif self.format_type == "date-time":
|
|
145
|
+
message = "Invalid datetime (use ISO 8601)"
|
|
146
|
+
else:
|
|
147
|
+
message = f"Invalid {self.format_type} format"
|
|
148
|
+
|
|
149
|
+
raise ValidationError(message=message, cursor_position=len(text))
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class ElicitationForm:
|
|
153
|
+
"""Simplified elicitation form with all fields visible."""
|
|
154
|
+
|
|
155
|
+
def __init__(
|
|
156
|
+
self, schema: ElicitRequestedSchema, message: str, agent_name: str, server_name: str
|
|
157
|
+
):
|
|
158
|
+
self.schema = schema
|
|
159
|
+
self.message = message
|
|
160
|
+
self.agent_name = agent_name
|
|
161
|
+
self.server_name = server_name
|
|
162
|
+
|
|
163
|
+
# Parse schema
|
|
164
|
+
self.properties = schema.get("properties", {})
|
|
165
|
+
self.required_fields = schema.get("required", [])
|
|
166
|
+
|
|
167
|
+
# Field storage
|
|
168
|
+
self.field_widgets: dict[str, Any] = {}
|
|
169
|
+
self.multiline_fields: set[str] = set() # Track which fields are multiline
|
|
170
|
+
|
|
171
|
+
# Result
|
|
172
|
+
self.result = None
|
|
173
|
+
self.action = "cancel"
|
|
174
|
+
|
|
175
|
+
# Build form
|
|
176
|
+
self._build_form()
|
|
177
|
+
|
|
178
|
+
def _build_form(self):
|
|
179
|
+
"""Build the form layout."""
|
|
180
|
+
|
|
181
|
+
# Fast-agent provided data (Agent and MCP Server) - aligned labels
|
|
182
|
+
fastagent_info = FormattedText(
|
|
183
|
+
[
|
|
184
|
+
("class:label", "Agent: "),
|
|
185
|
+
("class:agent-name", self.agent_name),
|
|
186
|
+
("class:label", "\nMCP Server: "),
|
|
187
|
+
("class:server-name", self.server_name),
|
|
188
|
+
]
|
|
189
|
+
)
|
|
190
|
+
fastagent_header = Window(
|
|
191
|
+
FormattedTextControl(fastagent_info),
|
|
192
|
+
height=2, # Just agent and server lines
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
# MCP Server provided message
|
|
196
|
+
mcp_message = FormattedText([("class:message", self.message)])
|
|
197
|
+
mcp_header = Window(
|
|
198
|
+
FormattedTextControl(mcp_message),
|
|
199
|
+
height=len(self.message.split("\n")),
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
# Create sticky headers (outside scrollable area)
|
|
203
|
+
sticky_headers = HSplit(
|
|
204
|
+
[
|
|
205
|
+
Window(height=1), # Top padding
|
|
206
|
+
VSplit(
|
|
207
|
+
[
|
|
208
|
+
Window(width=2), # Left padding
|
|
209
|
+
fastagent_header, # Fast-agent info
|
|
210
|
+
Window(width=2), # Right padding
|
|
211
|
+
]
|
|
212
|
+
),
|
|
213
|
+
Window(height=1), # Spacing
|
|
214
|
+
VSplit(
|
|
215
|
+
[
|
|
216
|
+
Window(width=2), # Left padding
|
|
217
|
+
mcp_header, # MCP server message
|
|
218
|
+
Window(width=2), # Right padding
|
|
219
|
+
]
|
|
220
|
+
),
|
|
221
|
+
Window(height=1), # Spacing
|
|
222
|
+
]
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
# Create scrollable form fields (without headers)
|
|
226
|
+
form_fields = []
|
|
227
|
+
|
|
228
|
+
for field_name, field_def in self.properties.items():
|
|
229
|
+
field_widget = self._create_field(field_name, field_def)
|
|
230
|
+
if field_widget:
|
|
231
|
+
form_fields.append(field_widget)
|
|
232
|
+
form_fields.append(Window(height=1)) # Spacing
|
|
233
|
+
|
|
234
|
+
# Status line for error display (disabled ValidationToolbar to avoid confusion)
|
|
235
|
+
self.status_control = FormattedTextControl(text="")
|
|
236
|
+
self.status_line = Window(
|
|
237
|
+
self.status_control, height=1
|
|
238
|
+
) # Store reference for later clearing
|
|
239
|
+
|
|
240
|
+
# Buttons - ensure they accept focus
|
|
241
|
+
submit_btn = Button("Accept", handler=self._accept)
|
|
242
|
+
cancel_btn = Button("Cancel", handler=self._cancel)
|
|
243
|
+
decline_btn = Button("Decline", handler=self._decline)
|
|
244
|
+
cancel_all_btn = Button("Cancel All", handler=self._cancel_all)
|
|
245
|
+
|
|
246
|
+
# Store button references for focus debugging
|
|
247
|
+
self.buttons = [submit_btn, decline_btn, cancel_btn, cancel_all_btn]
|
|
248
|
+
|
|
249
|
+
buttons = VSplit(
|
|
250
|
+
[
|
|
251
|
+
submit_btn,
|
|
252
|
+
Window(width=2),
|
|
253
|
+
decline_btn,
|
|
254
|
+
Window(width=2),
|
|
255
|
+
cancel_btn,
|
|
256
|
+
Window(width=2),
|
|
257
|
+
cancel_all_btn,
|
|
258
|
+
]
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
# Main scrollable content (form fields and buttons only)
|
|
262
|
+
form_fields.extend([self.status_line, buttons])
|
|
263
|
+
scrollable_form_content = HSplit(form_fields)
|
|
264
|
+
|
|
265
|
+
# Add padding around scrollable content
|
|
266
|
+
padded_scrollable_content = HSplit(
|
|
267
|
+
[
|
|
268
|
+
VSplit(
|
|
269
|
+
[
|
|
270
|
+
Window(width=2), # Left padding
|
|
271
|
+
scrollable_form_content,
|
|
272
|
+
Window(width=2), # Right padding
|
|
273
|
+
]
|
|
274
|
+
),
|
|
275
|
+
Window(height=1), # Bottom padding
|
|
276
|
+
]
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
# Wrap only form fields in ScrollablePane (headers stay fixed)
|
|
280
|
+
scrollable_content = ScrollablePane(
|
|
281
|
+
content=padded_scrollable_content,
|
|
282
|
+
show_scrollbar=False, # Only show when content exceeds available space
|
|
283
|
+
display_arrows=False, # Only show when content exceeds available space
|
|
284
|
+
keep_cursor_visible=True,
|
|
285
|
+
keep_focused_window_visible=True,
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
# Combine sticky headers and scrollable content (no separate title bar needed)
|
|
289
|
+
full_content = HSplit(
|
|
290
|
+
[
|
|
291
|
+
Window(height=1), # Top spacing
|
|
292
|
+
sticky_headers, # Headers stay fixed at top
|
|
293
|
+
scrollable_content, # Form fields can scroll
|
|
294
|
+
]
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
# Choose dialog title: prefer schema.title if provided
|
|
298
|
+
dialog_title = self.schema.get("title") if isinstance(self.schema, dict) else None
|
|
299
|
+
if not dialog_title or not isinstance(dialog_title, str):
|
|
300
|
+
dialog_title = "Elicitation Request"
|
|
301
|
+
|
|
302
|
+
# Create dialog frame with dynamic title
|
|
303
|
+
dialog = Frame(
|
|
304
|
+
body=full_content,
|
|
305
|
+
title=dialog_title,
|
|
306
|
+
style="class:dialog",
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
# Apply width constraints by putting Frame in VSplit with flexible spacers
|
|
310
|
+
# This prevents console display interference and constrains the Frame border
|
|
311
|
+
constrained_dialog = VSplit(
|
|
312
|
+
[
|
|
313
|
+
Window(width=10), # Smaller left spacer
|
|
314
|
+
dialog,
|
|
315
|
+
Window(width=10), # Smaller right spacer
|
|
316
|
+
]
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
# Use field navigation mode as default
|
|
320
|
+
global text_navigation_mode
|
|
321
|
+
text_navigation_mode = False
|
|
322
|
+
|
|
323
|
+
# Key bindings
|
|
324
|
+
kb = KeyBindings()
|
|
325
|
+
|
|
326
|
+
@kb.add("tab")
|
|
327
|
+
def focus_next_with_refresh(event):
|
|
328
|
+
focus_next(event)
|
|
329
|
+
|
|
330
|
+
@kb.add("s-tab")
|
|
331
|
+
def focus_previous_with_refresh(event):
|
|
332
|
+
focus_previous(event)
|
|
333
|
+
|
|
334
|
+
# Toggle between text navigation mode and field navigation mode
|
|
335
|
+
@kb.add("c-t")
|
|
336
|
+
def toggle_text_navigation_mode(event):
|
|
337
|
+
global text_navigation_mode
|
|
338
|
+
text_navigation_mode = not text_navigation_mode
|
|
339
|
+
event.app.invalidate() # Force redraw the app to update toolbar
|
|
340
|
+
|
|
341
|
+
# Arrow key navigation - let radio lists handle up/down first
|
|
342
|
+
@kb.add("down", filter=Condition(lambda: not text_navigation_mode))
|
|
343
|
+
def focus_next_arrow(event):
|
|
344
|
+
focus_next(event)
|
|
345
|
+
|
|
346
|
+
@kb.add("up", filter=Condition(lambda: not text_navigation_mode))
|
|
347
|
+
def focus_previous_arrow(event):
|
|
348
|
+
focus_previous(event)
|
|
349
|
+
|
|
350
|
+
@kb.add("right", eager=True, filter=Condition(lambda: not text_navigation_mode))
|
|
351
|
+
def focus_next_right(event):
|
|
352
|
+
focus_next(event)
|
|
353
|
+
|
|
354
|
+
@kb.add("left", eager=True, filter=Condition(lambda: not text_navigation_mode))
|
|
355
|
+
def focus_previous_left(event):
|
|
356
|
+
focus_previous(event)
|
|
357
|
+
|
|
358
|
+
# Enter submits in field navigation mode
|
|
359
|
+
@kb.add("c-m", filter=Condition(lambda: not text_navigation_mode))
|
|
360
|
+
def submit_enter(event):
|
|
361
|
+
self._accept()
|
|
362
|
+
|
|
363
|
+
# Ctrl+J inserts newlines in field navigation mode
|
|
364
|
+
@kb.add("c-j", filter=Condition(lambda: not text_navigation_mode))
|
|
365
|
+
def insert_newline_cj(event):
|
|
366
|
+
# Insert a newline at the cursor position
|
|
367
|
+
event.current_buffer.insert_text("\n")
|
|
368
|
+
# Mark this field as multiline when user adds a newline
|
|
369
|
+
for field_name, widget in self.field_widgets.items():
|
|
370
|
+
if isinstance(widget, Buffer) and widget == event.current_buffer:
|
|
371
|
+
self.multiline_fields.add(field_name)
|
|
372
|
+
break
|
|
373
|
+
|
|
374
|
+
# Enter inserts new lines in text navigation mode
|
|
375
|
+
@kb.add("c-m", filter=Condition(lambda: text_navigation_mode))
|
|
376
|
+
def insert_newline_enter(event):
|
|
377
|
+
# Insert a newline at the cursor position
|
|
378
|
+
event.current_buffer.insert_text("\n")
|
|
379
|
+
# Mark this field as multiline when user adds a newline
|
|
380
|
+
for field_name, widget in self.field_widgets.items():
|
|
381
|
+
if isinstance(widget, Buffer) and widget == event.current_buffer:
|
|
382
|
+
self.multiline_fields.add(field_name)
|
|
383
|
+
break
|
|
384
|
+
|
|
385
|
+
# deactivate ctrl+j in text navigation mode
|
|
386
|
+
@kb.add("c-j", filter=Condition(lambda: text_navigation_mode))
|
|
387
|
+
def _(event):
|
|
388
|
+
pass
|
|
389
|
+
|
|
390
|
+
# ESC should ALWAYS cancel immediately, no matter what
|
|
391
|
+
@kb.add("escape", eager=True, is_global=True)
|
|
392
|
+
def cancel(event):
|
|
393
|
+
self._cancel()
|
|
394
|
+
|
|
395
|
+
# Create a root layout with the dialog and bottom toolbar
|
|
396
|
+
def get_toolbar():
|
|
397
|
+
# When clearing, return empty to hide the toolbar completely
|
|
398
|
+
if hasattr(self, "_toolbar_hidden") and self._toolbar_hidden:
|
|
399
|
+
return FormattedText([])
|
|
400
|
+
|
|
401
|
+
mode_label = "TEXT MODE" if text_navigation_mode else "FIELD MODE"
|
|
402
|
+
mode_color = "ansired" if text_navigation_mode else "ansigreen"
|
|
403
|
+
|
|
404
|
+
arrow_up = "↑"
|
|
405
|
+
arrow_down = "↓"
|
|
406
|
+
arrow_left = "←"
|
|
407
|
+
arrow_right = "→"
|
|
408
|
+
|
|
409
|
+
if text_navigation_mode:
|
|
410
|
+
actions_line = " <ESC> cancel. <Cancel All> Auto-Cancel further elicitations from this Server."
|
|
411
|
+
navigation_tail = (
|
|
412
|
+
" | <CTRL+T> toggle text mode. <TAB> navigate. <ENTER> insert new line."
|
|
413
|
+
)
|
|
414
|
+
else:
|
|
415
|
+
actions_line = (
|
|
416
|
+
" <ENTER> submit. <ESC> cancel. <Cancel All> Auto-Cancel further elicitations "
|
|
417
|
+
"from this Server."
|
|
418
|
+
)
|
|
419
|
+
navigation_tail = (
|
|
420
|
+
" | <CTRL+T> toggle text mode. "
|
|
421
|
+
f"<TAB>/{arrow_up}{arrow_down}{arrow_right}{arrow_left} navigate. "
|
|
422
|
+
"<Ctrl+J> insert new line."
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
formatted_segments = [
|
|
426
|
+
("class:bottom-toolbar.text", actions_line),
|
|
427
|
+
("", "\n"),
|
|
428
|
+
("class:bottom-toolbar.text", " | "),
|
|
429
|
+
(f"fg:{mode_color} bg:ansiblack", f" {mode_label} "),
|
|
430
|
+
("class:bottom-toolbar.text", navigation_tail),
|
|
431
|
+
]
|
|
432
|
+
return FormattedText(formatted_segments)
|
|
433
|
+
|
|
434
|
+
# Store toolbar function reference for later control
|
|
435
|
+
self._get_toolbar = get_toolbar
|
|
436
|
+
self._dialog = dialog
|
|
437
|
+
|
|
438
|
+
# Create toolbar window that we can reference later
|
|
439
|
+
self._toolbar_window = Window(
|
|
440
|
+
FormattedTextControl(get_toolbar), height=2, style="class:bottom-toolbar"
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
# Add toolbar to the layout
|
|
444
|
+
root_layout = HSplit(
|
|
445
|
+
[
|
|
446
|
+
constrained_dialog, # The width-constrained dialog
|
|
447
|
+
self._toolbar_window,
|
|
448
|
+
]
|
|
449
|
+
)
|
|
450
|
+
self._root_layout = root_layout
|
|
451
|
+
|
|
452
|
+
# Application with toolbar and validation - ensure our styles override defaults
|
|
453
|
+
self.app = Application(
|
|
454
|
+
layout=Layout(root_layout),
|
|
455
|
+
key_bindings=kb,
|
|
456
|
+
full_screen=False, # Back to windowed mode for better integration
|
|
457
|
+
mouse_support=False,
|
|
458
|
+
style=ELICITATION_STYLE,
|
|
459
|
+
include_default_pygments_style=False, # Use only our custom style
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
# Set initial focus to first form field
|
|
463
|
+
def set_initial_focus():
|
|
464
|
+
try:
|
|
465
|
+
# Find first form field to focus on
|
|
466
|
+
first_field = None
|
|
467
|
+
for field_name in self.properties.keys():
|
|
468
|
+
widget = self.field_widgets.get(field_name)
|
|
469
|
+
if widget:
|
|
470
|
+
first_field = widget
|
|
471
|
+
break
|
|
472
|
+
|
|
473
|
+
if first_field:
|
|
474
|
+
self.app.layout.focus(first_field)
|
|
475
|
+
else:
|
|
476
|
+
# Fallback to first button if no fields
|
|
477
|
+
self.app.layout.focus(submit_btn)
|
|
478
|
+
except Exception:
|
|
479
|
+
pass # If focus fails, continue without it
|
|
480
|
+
|
|
481
|
+
# Schedule focus setting for after layout is ready
|
|
482
|
+
self.app.invalidate() # Ensure layout is built
|
|
483
|
+
set_initial_focus()
|
|
484
|
+
|
|
485
|
+
def _extract_enum_schema_options(self, schema_def: dict[str, Any]) -> list[Tuple[str, str]]:
|
|
486
|
+
"""Extract options from oneOf/anyOf/enum schema patterns.
|
|
487
|
+
|
|
488
|
+
Args:
|
|
489
|
+
schema_def: Schema definition potentially containing oneOf/anyOf/enum
|
|
490
|
+
|
|
491
|
+
Returns:
|
|
492
|
+
List of (value, title) tuples for the options
|
|
493
|
+
"""
|
|
494
|
+
values = []
|
|
495
|
+
|
|
496
|
+
# First check for bare enum (most common pattern for arrays)
|
|
497
|
+
if "enum" in schema_def:
|
|
498
|
+
enum_values = schema_def["enum"]
|
|
499
|
+
enum_names = schema_def.get("enumNames", enum_values)
|
|
500
|
+
for val, name in zip(enum_values, enum_names):
|
|
501
|
+
values.append((val, str(name)))
|
|
502
|
+
return values
|
|
503
|
+
|
|
504
|
+
# Then check for oneOf/anyOf patterns
|
|
505
|
+
options = schema_def.get("oneOf", [])
|
|
506
|
+
if not options:
|
|
507
|
+
options = schema_def.get("anyOf", [])
|
|
508
|
+
|
|
509
|
+
for option in options:
|
|
510
|
+
if "const" in option:
|
|
511
|
+
value = option["const"]
|
|
512
|
+
title = option.get("title", str(value))
|
|
513
|
+
values.append((value, title))
|
|
514
|
+
|
|
515
|
+
return values
|
|
516
|
+
|
|
517
|
+
def _extract_string_constraints(self, field_def: dict[str, Any]) -> dict[str, Any]:
|
|
518
|
+
"""Extract string constraints from field definition, handling anyOf schemas."""
|
|
519
|
+
constraints = {}
|
|
520
|
+
|
|
521
|
+
# Check direct constraints
|
|
522
|
+
if field_def.get("minLength") is not None:
|
|
523
|
+
constraints["minLength"] = field_def["minLength"]
|
|
524
|
+
if field_def.get("maxLength") is not None:
|
|
525
|
+
constraints["maxLength"] = field_def["maxLength"]
|
|
526
|
+
if field_def.get("pattern") is not None:
|
|
527
|
+
constraints["pattern"] = field_def["pattern"]
|
|
528
|
+
|
|
529
|
+
# Check anyOf constraints (for Optional fields)
|
|
530
|
+
if "anyOf" in field_def:
|
|
531
|
+
for variant in field_def["anyOf"]:
|
|
532
|
+
if variant.get("type") == "string":
|
|
533
|
+
if variant.get("minLength") is not None:
|
|
534
|
+
constraints["minLength"] = variant["minLength"]
|
|
535
|
+
if variant.get("maxLength") is not None:
|
|
536
|
+
constraints["maxLength"] = variant["maxLength"]
|
|
537
|
+
if variant.get("pattern") is not None:
|
|
538
|
+
constraints["pattern"] = variant["pattern"]
|
|
539
|
+
break
|
|
540
|
+
|
|
541
|
+
return constraints
|
|
542
|
+
|
|
543
|
+
def _create_field(self, field_name: str, field_def: dict[str, Any]):
|
|
544
|
+
"""Create a field widget."""
|
|
545
|
+
|
|
546
|
+
field_type = field_def.get("type", "string")
|
|
547
|
+
title = field_def.get("title", field_name)
|
|
548
|
+
description = field_def.get("description", "")
|
|
549
|
+
is_required = field_name in self.required_fields
|
|
550
|
+
|
|
551
|
+
# Build label with validation hints
|
|
552
|
+
label_text = title
|
|
553
|
+
if is_required:
|
|
554
|
+
label_text += " *"
|
|
555
|
+
if description:
|
|
556
|
+
label_text += f" - {description}"
|
|
557
|
+
|
|
558
|
+
# Add validation hints (simple ones stay on same line)
|
|
559
|
+
hints = []
|
|
560
|
+
format_hint = None
|
|
561
|
+
|
|
562
|
+
# Check if this is an array type with enum/oneOf/anyOf items
|
|
563
|
+
if field_type == "array" and "items" in field_def:
|
|
564
|
+
items_def = field_def["items"]
|
|
565
|
+
|
|
566
|
+
# Add minItems/maxItems hints
|
|
567
|
+
min_items = field_def.get("minItems")
|
|
568
|
+
max_items = field_def.get("maxItems")
|
|
569
|
+
|
|
570
|
+
if min_items is not None and max_items is not None:
|
|
571
|
+
if min_items == max_items:
|
|
572
|
+
hints.append(f"select exactly {min_items}")
|
|
573
|
+
else:
|
|
574
|
+
hints.append(f"select {min_items}-{max_items}")
|
|
575
|
+
elif min_items is not None:
|
|
576
|
+
hints.append(f"select at least {min_items}")
|
|
577
|
+
elif max_items is not None:
|
|
578
|
+
hints.append(f"select up to {max_items}")
|
|
579
|
+
|
|
580
|
+
if field_type == "string":
|
|
581
|
+
constraints = self._extract_string_constraints(field_def)
|
|
582
|
+
if constraints.get("minLength"):
|
|
583
|
+
hints.append(f"min {constraints['minLength']} chars")
|
|
584
|
+
if constraints.get("maxLength"):
|
|
585
|
+
hints.append(f"max {constraints['maxLength']} chars")
|
|
586
|
+
|
|
587
|
+
if constraints.get("pattern"):
|
|
588
|
+
# TODO: Wrap or truncate line if too long
|
|
589
|
+
format_hint = f"Pattern: {constraints['pattern']}"
|
|
590
|
+
|
|
591
|
+
# Handle format hints separately (these go on next line)
|
|
592
|
+
format_type = field_def.get("format")
|
|
593
|
+
if format_type:
|
|
594
|
+
format_info = {
|
|
595
|
+
"email": ("Email", "user@example.com"),
|
|
596
|
+
"uri": ("URI", "https://example.com"),
|
|
597
|
+
"date": ("Date", "YYYY-MM-DD"),
|
|
598
|
+
"date-time": ("Date Time", "YYYY-MM-DD HH:MM:SS"),
|
|
599
|
+
}
|
|
600
|
+
if format_type in format_info:
|
|
601
|
+
friendly_name, example = format_info[format_type]
|
|
602
|
+
format_hint = f"{friendly_name}: {example}"
|
|
603
|
+
else:
|
|
604
|
+
format_hint = format_type
|
|
605
|
+
|
|
606
|
+
elif field_type in ["number", "integer"]:
|
|
607
|
+
if field_def.get("minimum") is not None:
|
|
608
|
+
hints.append(f"min {field_def['minimum']}")
|
|
609
|
+
if field_def.get("maximum") is not None:
|
|
610
|
+
hints.append(f"max {field_def['maximum']}")
|
|
611
|
+
elif field_type == "string" and "enum" in field_def:
|
|
612
|
+
enum_names = field_def.get("enumNames", field_def["enum"])
|
|
613
|
+
hints.append(f"choose from: {', '.join(enum_names)}")
|
|
614
|
+
|
|
615
|
+
# Add simple hints to main label line
|
|
616
|
+
if hints:
|
|
617
|
+
label_text += f" ({', '.join(hints)})"
|
|
618
|
+
|
|
619
|
+
# Create multiline label if we have format hints
|
|
620
|
+
if format_hint:
|
|
621
|
+
label_lines = [label_text, f" → {format_hint}"]
|
|
622
|
+
label = Label(text="\n".join(label_lines))
|
|
623
|
+
else:
|
|
624
|
+
label = Label(text=label_text)
|
|
625
|
+
|
|
626
|
+
# Create input widget based on type
|
|
627
|
+
if field_type == "boolean":
|
|
628
|
+
default = field_def.get("default", False)
|
|
629
|
+
checkbox = Checkbox(text="Yes")
|
|
630
|
+
checkbox.checked = default
|
|
631
|
+
self.field_widgets[field_name] = checkbox
|
|
632
|
+
|
|
633
|
+
return HSplit([label, Frame(checkbox)])
|
|
634
|
+
|
|
635
|
+
elif field_type == "string" and "enum" in field_def:
|
|
636
|
+
# Leaving this here for existing enum schema
|
|
637
|
+
enum_values = field_def["enum"]
|
|
638
|
+
enum_names = field_def.get("enumNames", enum_values)
|
|
639
|
+
values = [(val, name) for val, name in zip(enum_values, enum_names)]
|
|
640
|
+
|
|
641
|
+
default_value = field_def.get("default")
|
|
642
|
+
radio_list = RadioList(values=values, default=default_value)
|
|
643
|
+
self.field_widgets[field_name] = radio_list
|
|
644
|
+
|
|
645
|
+
return HSplit([label, Frame(radio_list, height=min(len(values) + 2, 6))])
|
|
646
|
+
|
|
647
|
+
elif field_type == "string" and "oneOf" in field_def:
|
|
648
|
+
# Handle oneOf pattern for single selection enums
|
|
649
|
+
values = self._extract_enum_schema_options(field_def)
|
|
650
|
+
if values:
|
|
651
|
+
default_value = field_def.get("default")
|
|
652
|
+
radio_list = RadioList(values=values, default=default_value)
|
|
653
|
+
self.field_widgets[field_name] = radio_list
|
|
654
|
+
return HSplit([label, Frame(radio_list, height=min(len(values) + 2, 6))])
|
|
655
|
+
|
|
656
|
+
elif field_type == "array" and "items" in field_def:
|
|
657
|
+
# Handle array types with enum/oneOf/anyOf items
|
|
658
|
+
items_def = field_def["items"]
|
|
659
|
+
values = self._extract_enum_schema_options(items_def)
|
|
660
|
+
if values:
|
|
661
|
+
# Create checkbox list for multi-selection
|
|
662
|
+
min_items = field_def.get("minItems")
|
|
663
|
+
max_items = field_def.get("maxItems")
|
|
664
|
+
default_values = field_def.get("default", [])
|
|
665
|
+
|
|
666
|
+
checkbox_list = ValidatedCheckboxList(
|
|
667
|
+
values=values,
|
|
668
|
+
default_values=default_values,
|
|
669
|
+
min_items=min_items,
|
|
670
|
+
max_items=max_items,
|
|
671
|
+
)
|
|
672
|
+
|
|
673
|
+
# Store the widget directly (consistent with other widgets)
|
|
674
|
+
self.field_widgets[field_name] = checkbox_list
|
|
675
|
+
|
|
676
|
+
# Create scrollable frame if many options
|
|
677
|
+
height = min(len(values) + 2, 8)
|
|
678
|
+
return HSplit([label, Frame(checkbox_list, height=height)])
|
|
679
|
+
|
|
680
|
+
else:
|
|
681
|
+
# Text/number input
|
|
682
|
+
validator: Validator | None = None
|
|
683
|
+
|
|
684
|
+
if field_type in ["number", "integer"]:
|
|
685
|
+
validator = SimpleNumberValidator(
|
|
686
|
+
field_type=field_type,
|
|
687
|
+
minimum=field_def.get("minimum"),
|
|
688
|
+
maximum=field_def.get("maximum"),
|
|
689
|
+
)
|
|
690
|
+
elif field_type == "string":
|
|
691
|
+
constraints = self._extract_string_constraints(field_def)
|
|
692
|
+
format_type = field_def.get("format")
|
|
693
|
+
|
|
694
|
+
if format_type in ["email", "uri", "date", "date-time"]:
|
|
695
|
+
# Use format validator for specific formats
|
|
696
|
+
validator = FormatValidator(format_type)
|
|
697
|
+
else:
|
|
698
|
+
# Use string length validator for regular strings
|
|
699
|
+
validator = SimpleStringValidator(
|
|
700
|
+
min_length=constraints.get("minLength"),
|
|
701
|
+
max_length=constraints.get("maxLength"),
|
|
702
|
+
pattern=constraints.get("pattern"),
|
|
703
|
+
)
|
|
704
|
+
else:
|
|
705
|
+
constraints = {}
|
|
706
|
+
|
|
707
|
+
default_value = field_def.get("default")
|
|
708
|
+
|
|
709
|
+
# Determine if field should be multiline based on max_length or default value length
|
|
710
|
+
if field_type == "string":
|
|
711
|
+
max_length = constraints.get("maxLength")
|
|
712
|
+
# Check default value length if maxLength not specified
|
|
713
|
+
if not max_length and default_value is not None:
|
|
714
|
+
max_length = len(str(default_value))
|
|
715
|
+
else:
|
|
716
|
+
max_length = None
|
|
717
|
+
|
|
718
|
+
# Check if default value contains newlines
|
|
719
|
+
if field_type == "string" and default_value is not None and "\n" in str(default_value):
|
|
720
|
+
multiline = True
|
|
721
|
+
self.multiline_fields.add(field_name) # Track multiline fields
|
|
722
|
+
# Set height to actual line count for fields with newlines in default
|
|
723
|
+
initial_height = str(default_value).count("\n") + 1
|
|
724
|
+
elif max_length and max_length > 100:
|
|
725
|
+
# Use multiline for longer fields
|
|
726
|
+
multiline = True
|
|
727
|
+
self.multiline_fields.add(field_name) # Track multiline fields
|
|
728
|
+
if max_length <= 300:
|
|
729
|
+
initial_height = 3
|
|
730
|
+
else:
|
|
731
|
+
initial_height = 5
|
|
732
|
+
else:
|
|
733
|
+
# Single line for shorter fields
|
|
734
|
+
multiline = False
|
|
735
|
+
initial_height = 1
|
|
736
|
+
|
|
737
|
+
buffer = Buffer(
|
|
738
|
+
validator=validator,
|
|
739
|
+
multiline=multiline,
|
|
740
|
+
validate_while_typing=True, # Enable real-time validation
|
|
741
|
+
complete_while_typing=False, # Disable completion for cleaner experience
|
|
742
|
+
enable_history_search=False, # Disable history for cleaner experience
|
|
743
|
+
)
|
|
744
|
+
if default_value is not None:
|
|
745
|
+
buffer.text = str(default_value)
|
|
746
|
+
self.field_widgets[field_name] = buffer
|
|
747
|
+
|
|
748
|
+
# Create dynamic style function for focus highlighting and validation errors
|
|
749
|
+
def get_field_style():
|
|
750
|
+
"""Dynamic style that changes based on focus and validation state."""
|
|
751
|
+
from prompt_toolkit.application.current import get_app
|
|
752
|
+
|
|
753
|
+
# Check if buffer has validation errors
|
|
754
|
+
if buffer.validation_error:
|
|
755
|
+
return "class:input-field.error"
|
|
756
|
+
elif get_app().layout.has_focus(buffer):
|
|
757
|
+
return "class:input-field.focused"
|
|
758
|
+
else:
|
|
759
|
+
return "class:input-field"
|
|
760
|
+
|
|
761
|
+
# Create a dynamic height function based on content
|
|
762
|
+
def get_dynamic_height():
|
|
763
|
+
if not buffer.text:
|
|
764
|
+
return initial_height
|
|
765
|
+
# Calculate height based on number of newlines in buffer
|
|
766
|
+
line_count = buffer.text.count("\n") + 1
|
|
767
|
+
# Use initial height as minimum, grow up to 20 lines
|
|
768
|
+
return min(max(line_count, initial_height), 20)
|
|
769
|
+
|
|
770
|
+
text_input = Window(
|
|
771
|
+
BufferControl(buffer=buffer),
|
|
772
|
+
height=get_dynamic_height, # Use dynamic height function
|
|
773
|
+
style=get_field_style, # Use dynamic style function
|
|
774
|
+
wrap_lines=True if multiline else False, # Enable word wrap for multiline
|
|
775
|
+
)
|
|
776
|
+
|
|
777
|
+
return HSplit([label, Frame(text_input)])
|
|
778
|
+
|
|
779
|
+
def _validate_form(self) -> tuple[bool, str | None]:
|
|
780
|
+
"""Validate the entire form."""
|
|
781
|
+
|
|
782
|
+
# First, check all fields for validation errors from their validators
|
|
783
|
+
for field_name, field_def in self.properties.items():
|
|
784
|
+
widget = self.field_widgets.get(field_name)
|
|
785
|
+
if widget is None:
|
|
786
|
+
continue
|
|
787
|
+
|
|
788
|
+
# Check for validation errors from validators
|
|
789
|
+
if isinstance(widget, Buffer):
|
|
790
|
+
if widget.validation_error:
|
|
791
|
+
title = field_def.get("title", field_name)
|
|
792
|
+
return False, f"'{title}': {widget.validation_error.message}"
|
|
793
|
+
elif isinstance(widget, ValidatedCheckboxList):
|
|
794
|
+
if widget.validation_error:
|
|
795
|
+
title = field_def.get("title", field_name)
|
|
796
|
+
return False, f"'{title}': {widget.validation_error.message}"
|
|
797
|
+
|
|
798
|
+
# Then check if required fields are empty
|
|
799
|
+
for field_name in self.required_fields:
|
|
800
|
+
widget = self.field_widgets.get(field_name)
|
|
801
|
+
if widget is None:
|
|
802
|
+
continue
|
|
803
|
+
|
|
804
|
+
# Check if required field has value
|
|
805
|
+
if isinstance(widget, Buffer):
|
|
806
|
+
if not widget.text.strip():
|
|
807
|
+
title = self.properties[field_name].get("title", field_name)
|
|
808
|
+
return False, f"'{title}' is required"
|
|
809
|
+
elif isinstance(widget, RadioList):
|
|
810
|
+
if widget.current_value is None:
|
|
811
|
+
title = self.properties[field_name].get("title", field_name)
|
|
812
|
+
return False, f"'{title}' is required"
|
|
813
|
+
elif isinstance(widget, ValidatedCheckboxList):
|
|
814
|
+
if not widget.current_values:
|
|
815
|
+
title = self.properties[field_name].get("title", field_name)
|
|
816
|
+
return False, f"'{title}' is required"
|
|
817
|
+
|
|
818
|
+
return True, None
|
|
819
|
+
|
|
820
|
+
def _get_form_data(self) -> dict[str, Any]:
|
|
821
|
+
"""Extract data from form fields."""
|
|
822
|
+
data: dict[str, Any] = {}
|
|
823
|
+
|
|
824
|
+
for field_name, field_def in self.properties.items():
|
|
825
|
+
widget = self.field_widgets.get(field_name)
|
|
826
|
+
if widget is None:
|
|
827
|
+
continue
|
|
828
|
+
|
|
829
|
+
field_type = field_def.get("type", "string")
|
|
830
|
+
|
|
831
|
+
if isinstance(widget, Buffer):
|
|
832
|
+
value = widget.text.strip()
|
|
833
|
+
if value:
|
|
834
|
+
if field_type == "integer":
|
|
835
|
+
try:
|
|
836
|
+
data[field_name] = int(value)
|
|
837
|
+
except ValueError:
|
|
838
|
+
# This should not happen due to validation, but be safe
|
|
839
|
+
raise ValueError(f"Invalid integer value for {field_name}: {value}")
|
|
840
|
+
elif field_type == "number":
|
|
841
|
+
try:
|
|
842
|
+
data[field_name] = float(value)
|
|
843
|
+
except ValueError:
|
|
844
|
+
# This should not happen due to validation, but be safe
|
|
845
|
+
raise ValueError(f"Invalid number value for {field_name}: {value}")
|
|
846
|
+
else:
|
|
847
|
+
data[field_name] = value
|
|
848
|
+
elif field_name not in self.required_fields:
|
|
849
|
+
data[field_name] = None
|
|
850
|
+
|
|
851
|
+
elif isinstance(widget, Checkbox):
|
|
852
|
+
data[field_name] = widget.checked
|
|
853
|
+
|
|
854
|
+
elif isinstance(widget, RadioList):
|
|
855
|
+
if widget.current_value is not None:
|
|
856
|
+
data[field_name] = widget.current_value
|
|
857
|
+
|
|
858
|
+
elif isinstance(widget, ValidatedCheckboxList):
|
|
859
|
+
selected_values = widget.current_values
|
|
860
|
+
if selected_values:
|
|
861
|
+
data[field_name] = list(selected_values)
|
|
862
|
+
elif field_name not in self.required_fields:
|
|
863
|
+
data[field_name] = []
|
|
864
|
+
|
|
865
|
+
return data
|
|
866
|
+
|
|
867
|
+
def _accept(self):
|
|
868
|
+
"""Handle form submission."""
|
|
869
|
+
# Validate
|
|
870
|
+
is_valid, error_msg = self._validate_form()
|
|
871
|
+
if not is_valid:
|
|
872
|
+
# Use styled error message
|
|
873
|
+
self.status_control.text = FormattedText(
|
|
874
|
+
[("class:validation-error", f"Error: {error_msg}")]
|
|
875
|
+
)
|
|
876
|
+
return
|
|
877
|
+
|
|
878
|
+
# Get data
|
|
879
|
+
try:
|
|
880
|
+
self.result = self._get_form_data()
|
|
881
|
+
self.action = "accept"
|
|
882
|
+
self._clear_status_bar()
|
|
883
|
+
self.app.exit()
|
|
884
|
+
except Exception as e:
|
|
885
|
+
# Use styled error message
|
|
886
|
+
self.status_control.text = FormattedText(
|
|
887
|
+
[("class:validation-error", f"Error: {str(e)}")]
|
|
888
|
+
)
|
|
889
|
+
|
|
890
|
+
def _cancel(self):
|
|
891
|
+
"""Handle cancel."""
|
|
892
|
+
self.action = "cancel"
|
|
893
|
+
self._clear_status_bar()
|
|
894
|
+
self.app.exit()
|
|
895
|
+
|
|
896
|
+
def _decline(self):
|
|
897
|
+
"""Handle decline."""
|
|
898
|
+
self.action = "decline"
|
|
899
|
+
self._clear_status_bar()
|
|
900
|
+
self.app.exit()
|
|
901
|
+
|
|
902
|
+
def _cancel_all(self):
|
|
903
|
+
"""Handle cancel all: signal disable; no side effects here.
|
|
904
|
+
|
|
905
|
+
UI emits an action; handler/orchestration is responsible for updating state.
|
|
906
|
+
"""
|
|
907
|
+
self.action = "disable"
|
|
908
|
+
self._clear_status_bar()
|
|
909
|
+
self.app.exit()
|
|
910
|
+
|
|
911
|
+
def _clear_status_bar(self):
|
|
912
|
+
"""Hide the status bar by removing it from the layout."""
|
|
913
|
+
# Create completely clean layout - just empty space with application background
|
|
914
|
+
from prompt_toolkit.layout import HSplit, Window
|
|
915
|
+
from prompt_toolkit.layout.controls import FormattedTextControl
|
|
916
|
+
|
|
917
|
+
# Create a simple empty window with application background
|
|
918
|
+
empty_window = Window(
|
|
919
|
+
FormattedTextControl(FormattedText([("class:application", "")])), height=1
|
|
920
|
+
)
|
|
921
|
+
|
|
922
|
+
# Replace entire layout with just the empty window
|
|
923
|
+
new_layout = HSplit([empty_window])
|
|
924
|
+
|
|
925
|
+
# Update the app's layout
|
|
926
|
+
if hasattr(self, "app") and self.app:
|
|
927
|
+
self.app.layout.container = new_layout
|
|
928
|
+
self.app.invalidate()
|
|
929
|
+
|
|
930
|
+
async def run_async(self) -> tuple[str, dict[str, Any] | None]:
|
|
931
|
+
"""Run the form and return result."""
|
|
932
|
+
try:
|
|
933
|
+
await self.app.run_async()
|
|
934
|
+
except Exception as e:
|
|
935
|
+
print(f"Form error: {e}")
|
|
936
|
+
self.action = "cancel"
|
|
937
|
+
self._clear_status_bar()
|
|
938
|
+
return self.action, self.result
|
|
939
|
+
|
|
940
|
+
|
|
941
|
+
async def show_simple_elicitation_form(
|
|
942
|
+
schema: ElicitRequestedSchema, message: str, agent_name: str, server_name: str
|
|
943
|
+
) -> tuple[str, dict[str, Any] | None]:
|
|
944
|
+
"""Show the simplified elicitation form."""
|
|
945
|
+
form = ElicitationForm(schema, message, agent_name, server_name)
|
|
946
|
+
return await form.run_async()
|