idun-agent-engine 0.3.9__py3-none-any.whl → 0.4.1__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 (45) hide show
  1. idun_agent_engine/_version.py +1 -1
  2. idun_agent_engine/agent/adk/adk.py +5 -2
  3. idun_agent_engine/agent/langgraph/langgraph.py +1 -1
  4. idun_agent_engine/core/app_factory.py +1 -1
  5. idun_agent_engine/core/config_builder.py +11 -5
  6. idun_agent_engine/guardrails/guardrails_hub/__init__.py +2 -2
  7. idun_agent_engine/mcp/__init__.py +18 -2
  8. idun_agent_engine/mcp/helpers.py +135 -43
  9. idun_agent_engine/mcp/registry.py +7 -1
  10. idun_agent_engine/server/lifespan.py +22 -0
  11. idun_agent_engine/telemetry/__init__.py +19 -0
  12. idun_agent_engine/telemetry/config.py +29 -0
  13. idun_agent_engine/telemetry/telemetry.py +248 -0
  14. {idun_agent_engine-0.3.9.dist-info → idun_agent_engine-0.4.1.dist-info}/METADATA +12 -8
  15. {idun_agent_engine-0.3.9.dist-info → idun_agent_engine-0.4.1.dist-info}/RECORD +45 -17
  16. idun_platform_cli/groups/agent/package.py +3 -0
  17. idun_platform_cli/groups/agent/serve.py +2 -0
  18. idun_platform_cli/groups/init.py +25 -0
  19. idun_platform_cli/main.py +3 -0
  20. idun_platform_cli/telemetry.py +54 -0
  21. idun_platform_cli/tui/__init__.py +0 -0
  22. idun_platform_cli/tui/css/__init__.py +0 -0
  23. idun_platform_cli/tui/css/create_agent.py +912 -0
  24. idun_platform_cli/tui/css/main.py +89 -0
  25. idun_platform_cli/tui/main.py +87 -0
  26. idun_platform_cli/tui/schemas/__init__.py +0 -0
  27. idun_platform_cli/tui/schemas/create_agent.py +60 -0
  28. idun_platform_cli/tui/screens/__init__.py +0 -0
  29. idun_platform_cli/tui/screens/create_agent.py +622 -0
  30. idun_platform_cli/tui/utils/__init__.py +0 -0
  31. idun_platform_cli/tui/utils/config.py +182 -0
  32. idun_platform_cli/tui/validators/__init__.py +0 -0
  33. idun_platform_cli/tui/validators/guardrails.py +88 -0
  34. idun_platform_cli/tui/validators/mcps.py +84 -0
  35. idun_platform_cli/tui/validators/observability.py +65 -0
  36. idun_platform_cli/tui/widgets/__init__.py +19 -0
  37. idun_platform_cli/tui/widgets/chat_widget.py +153 -0
  38. idun_platform_cli/tui/widgets/guardrails_widget.py +356 -0
  39. idun_platform_cli/tui/widgets/identity_widget.py +252 -0
  40. idun_platform_cli/tui/widgets/mcps_widget.py +230 -0
  41. idun_platform_cli/tui/widgets/memory_widget.py +195 -0
  42. idun_platform_cli/tui/widgets/observability_widget.py +382 -0
  43. idun_platform_cli/tui/widgets/serve_widget.py +82 -0
  44. {idun_agent_engine-0.3.9.dist-info → idun_agent_engine-0.4.1.dist-info}/WHEEL +0 -0
  45. {idun_agent_engine-0.3.9.dist-info → idun_agent_engine-0.4.1.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,182 @@
1
+ from pathlib import Path
2
+ from typing import Any
3
+
4
+ import yaml
5
+ from idun_agent_schema.engine.guardrails_v2 import GuardrailsV2
6
+ from idun_agent_schema.engine.observability_v2 import ObservabilityConfig
7
+ from pydantic import ValidationError
8
+
9
+ from idun_platform_cli.tui.schemas.create_agent import (
10
+ TUIAgentConfig,
11
+ )
12
+
13
+
14
+ class ConfigManager:
15
+ def __init__(self):
16
+ self.idun_dir = Path.home() / ".idun"
17
+ self.agent_path = None
18
+ try:
19
+ self.idun_dir.mkdir(exist_ok=True)
20
+
21
+ except OSError as e:
22
+ raise ValueError(
23
+ f"Error while preparing `.idun` config file: {e}\nNote: This file is used to store config and env data"
24
+ ) from e
25
+
26
+ def _sanitize_agent_name(self, agent_name: str) -> str:
27
+ return agent_name.lstrip().replace("-", "_").replace(" ", "_")
28
+
29
+ def _validate_data(
30
+ self, config: dict[str, Any]
31
+ ) -> tuple[TUIAgentConfig | None, str]:
32
+ try:
33
+ return TUIAgentConfig.model_validate(config), "valid"
34
+ except Exception as e:
35
+ return None, f"Error: cannot validate config: {e}"
36
+
37
+ def save_config(self, config: dict) -> tuple[bool, str]:
38
+ try:
39
+ raw_agent_name: str = config["name"]
40
+ except KeyError as e:
41
+ raise ValueError(
42
+ "Agent name is not defined! Make sure you specify a name for your agent!"
43
+ ) from e
44
+ sanitized_agent_name = self._sanitize_agent_name(raw_agent_name)
45
+ self.agent_path = (self.idun_dir / sanitized_agent_name).with_suffix(".yaml")
46
+
47
+ with self.agent_path.open("w") as f:
48
+ serialized, msg = self._validate_data(config)
49
+ if serialized is None:
50
+ return False, msg
51
+ with self.agent_path.open("w") as f:
52
+ yaml.dump(serialized.to_engine_config(), f, default_flow_style=False)
53
+ return True, "Valid"
54
+
55
+ def load_draft(self) -> dict | None:
56
+ if self.agent_path is None or not self.agent_path.exists():
57
+ raise ValueError(
58
+ "No agent config file found. Make sure you have saved agent configs."
59
+ )
60
+ with self.agent_path.open("r") as f:
61
+ return yaml.safe_load(f) or {}
62
+
63
+ def save_partial(
64
+ self, section: str, data: dict | Any, agent_name: str = None
65
+ ) -> tuple[bool, str]:
66
+ try:
67
+ from idun_agent_engine.core.engine_config import EngineConfig
68
+
69
+ if agent_name:
70
+ sanitized_agent_name = self._sanitize_agent_name(agent_name)
71
+ self.agent_path = (self.idun_dir / sanitized_agent_name).with_suffix(
72
+ ".yaml"
73
+ )
74
+
75
+ if self.agent_path is None:
76
+ return False, "Agent name not set. Save identity section first."
77
+
78
+ existing_config = {}
79
+ if self.agent_path.exists():
80
+ with self.agent_path.open("r") as f:
81
+ existing_config = yaml.safe_load(f) or {}
82
+
83
+ if section == "identity":
84
+ from idun_platform_cli.tui.schemas.create_agent import TUIAgentConfig
85
+
86
+ tui_config = TUIAgentConfig.model_validate(data)
87
+ engine_config_dict = tui_config.to_engine_config()
88
+ existing_config["server"] = engine_config_dict["server"]
89
+ existing_config["agent"] = engine_config_dict["agent"]
90
+ elif section == "observability":
91
+ if isinstance(data, ObservabilityConfig):
92
+ obs_dict = {
93
+ "provider": data.provider.value,
94
+ "enabled": data.enabled,
95
+ "config": data.config.model_dump(by_alias=False),
96
+ }
97
+ existing_config["observability"] = [obs_dict]
98
+ else:
99
+ existing_config["observability"] = [data]
100
+ elif section == "guardrails":
101
+ if isinstance(data, GuardrailsV2):
102
+ guardrails_dict = {
103
+ "input": [
104
+ g.model_dump(by_alias=False, mode="json")
105
+ for g in data.input
106
+ ],
107
+ "output": [
108
+ g.model_dump(by_alias=False, mode="json")
109
+ for g in data.output
110
+ ],
111
+ }
112
+ existing_config["guardrails"] = guardrails_dict
113
+ else:
114
+ existing_config["guardrails"] = data
115
+ elif section == "mcp_servers":
116
+ if isinstance(data, list):
117
+ mcp_servers_list = []
118
+ for server in data:
119
+ if hasattr(server, "model_dump"):
120
+ mcp_servers_list.append(
121
+ server.model_dump(
122
+ by_alias=True, mode="json", exclude_none=True
123
+ )
124
+ )
125
+ else:
126
+ mcp_servers_list.append(server)
127
+ existing_config["mcp_servers"] = mcp_servers_list
128
+ else:
129
+ existing_config["mcp_servers"] = data
130
+ elif section == "memory":
131
+ from idun_agent_schema.engine.langgraph import CheckpointConfig
132
+
133
+ if "agent" not in existing_config:
134
+ return False, "Agent configuration not found. Save identity first."
135
+
136
+ agent_type = existing_config.get("agent", {}).get("type")
137
+ if agent_type != "LANGGRAPH":
138
+ return (
139
+ True,
140
+ "Checkpoint configuration skipped for non-LANGGRAPH agents",
141
+ )
142
+
143
+ if isinstance(data, CheckpointConfig):
144
+ checkpoint_dict = data.model_dump(by_alias=False, mode="json")
145
+
146
+ if "config" not in existing_config["agent"]:
147
+ existing_config["agent"]["config"] = {}
148
+
149
+ existing_config["agent"]["config"]["checkpointer"] = checkpoint_dict
150
+ else:
151
+ return False, "Invalid checkpoint configuration type"
152
+ else:
153
+ existing_config[section] = data
154
+
155
+ EngineConfig.model_validate(existing_config)
156
+
157
+ with self.agent_path.open("w") as f:
158
+ yaml.dump(existing_config, f, default_flow_style=False)
159
+
160
+ return True, "Saved successfully"
161
+
162
+ except ValidationError as e:
163
+ return False, f"Validation error: {e}"
164
+ except Exception as e:
165
+ return False, f"Error saving config: {e}"
166
+
167
+ def load_config(self, agent_name: str = None) -> dict:
168
+ try:
169
+ if agent_name:
170
+ sanitized_agent_name = self._sanitize_agent_name(agent_name)
171
+ self.agent_path = (self.idun_dir / sanitized_agent_name).with_suffix(
172
+ ".yaml"
173
+ )
174
+
175
+ if self.agent_path is None or not self.agent_path.exists():
176
+ return {}
177
+
178
+ with self.agent_path.open("r") as f:
179
+ return yaml.safe_load(f) or {}
180
+
181
+ except Exception as e:
182
+ return {}
File without changes
@@ -0,0 +1,88 @@
1
+ """Guardrails validation logic."""
2
+
3
+ from idun_agent_schema.engine.guardrails_v2 import (
4
+ GuardrailConfigId,
5
+ BiasCheckConfig,
6
+ ToxicLanguageConfig,
7
+ CompetitionCheckConfig,
8
+ BanListConfig,
9
+ DetectPIIConfig,
10
+ )
11
+
12
+
13
+ def validate_guardrail(guardrail_id: str, config: dict) -> tuple[any, str]:
14
+ try:
15
+ match guardrail_id:
16
+ case "bias_check":
17
+ threshold = float(config.get("threshold", 0.5))
18
+ validated = BiasCheckConfig(
19
+ config_id=GuardrailConfigId.BIAS_CHECK,
20
+ api_key=config.get("api_key", ""),
21
+ reject_message=config.get("reject_message", "Bias detected"),
22
+ threshold=threshold
23
+ )
24
+ return validated, "ok"
25
+
26
+ case "toxic_language":
27
+ threshold = float(config.get("threshold", 0.5))
28
+ validated = ToxicLanguageConfig(
29
+ config_id=GuardrailConfigId.TOXIC_LANGUAGE,
30
+ api_key=config.get("api_key", ""),
31
+ reject_message=config.get("reject_message", "Toxic language detected"),
32
+ threshold=threshold
33
+ )
34
+ return validated, "ok"
35
+
36
+ case "competition_check":
37
+ competitors = config.get("competitors", [])
38
+ if isinstance(competitors, str):
39
+ competitors = [
40
+ c.strip() for c in competitors.split(",") if c.strip()
41
+ ]
42
+ validated = CompetitionCheckConfig(
43
+ config_id=GuardrailConfigId.COMPETITION_CHECK,
44
+ api_key=config.get("api_key", ""),
45
+ reject_message=config.get("reject_message", "Competitor mentioned"),
46
+ competitors=competitors,
47
+ )
48
+ return validated, "ok"
49
+
50
+ case "ban_list":
51
+ banned_words = config.get("banned_words", [])
52
+ if isinstance(banned_words, str):
53
+ banned_words = [
54
+ w.strip() for w in banned_words.split(",") if w.strip()
55
+ ]
56
+
57
+ validated = BanListConfig(
58
+ config_id=GuardrailConfigId.BAN_LIST,
59
+ api_key=config.get("api_key", ""),
60
+ reject_message=config.get("reject_message", ""),
61
+ guard_params={"banned_words": banned_words},
62
+ )
63
+ return validated, "ok"
64
+
65
+ case "detect_pii":
66
+ pii_entities = config.get("pii_entities", [])
67
+ if isinstance(pii_entities, str):
68
+ pii_entities = [
69
+ e.strip() for e in pii_entities.split(",") if e.strip()
70
+ ]
71
+
72
+ validated = DetectPIIConfig(
73
+ config_id=GuardrailConfigId.DETECT_PII,
74
+ api_key=config.get("api_key", ""),
75
+ reject_message=config.get("reject_message", ""),
76
+ guard_params={"pii_entities": pii_entities, "on_fail": "exception"},
77
+ )
78
+ return validated, "ok"
79
+
80
+ case _:
81
+ return None, f"Unknown guardrail type: {guardrail_id}"
82
+
83
+ except Exception as e:
84
+ error_msg = str(e)
85
+ if len(error_msg) > 100:
86
+ error_msg = error_msg[:100] + "..."
87
+ error_msg = error_msg.replace("<", "").replace(">", "")
88
+ return None, f"Validation error for {guardrail_id}: {error_msg}"
@@ -0,0 +1,84 @@
1
+ """MCPs validation logic."""
2
+
3
+ from idun_agent_schema.engine.mcp_server import MCPServer
4
+
5
+
6
+ def validate_mcp_servers(
7
+ mcp_servers_data: list[dict],
8
+ ) -> tuple[list[MCPServer] | None, str]:
9
+ if not mcp_servers_data:
10
+ return [], "ok"
11
+
12
+ try:
13
+ validated_servers = []
14
+ seen_names = set()
15
+
16
+ for idx, server_data in enumerate(mcp_servers_data):
17
+ name = server_data.get("name", "")
18
+ if not name:
19
+ return None, f"Server {idx + 1}: Name is required"
20
+
21
+ if name in seen_names:
22
+ return None, f"Duplicate server name: {name}"
23
+ seen_names.add(name)
24
+
25
+ transport = server_data.get("transport", "streamable_http")
26
+
27
+ if transport == "stdio":
28
+ if not server_data.get("command"):
29
+ return (
30
+ None,
31
+ f"Server '{name}': command is required for stdio transport",
32
+ )
33
+ elif transport in ["sse", "streamable_http", "websocket"]:
34
+ if not server_data.get("url"):
35
+ return (
36
+ None,
37
+ f"Server '{name}': url is required for {transport} transport",
38
+ )
39
+
40
+ args = server_data.get("args", [])
41
+ if isinstance(args, str):
42
+ args = [a.strip() for a in args.split("\n") if a.strip()]
43
+
44
+ headers = server_data.get("headers", {})
45
+ if isinstance(headers, str):
46
+ import json
47
+
48
+ try:
49
+ headers = json.loads(headers) if headers.strip() else {}
50
+ except json.JSONDecodeError:
51
+ return None, f"Server '{name}': Invalid JSON for headers"
52
+
53
+ env = server_data.get("env", {})
54
+ if isinstance(env, str):
55
+ import json
56
+
57
+ try:
58
+ env = json.loads(env) if env.strip() else {}
59
+ except json.JSONDecodeError:
60
+ return None, f"Server '{name}': Invalid JSON for env"
61
+
62
+ server_config = {
63
+ "name": name,
64
+ "transport": transport,
65
+ }
66
+
67
+ if server_data.get("url"):
68
+ server_config["url"] = server_data["url"]
69
+ if headers:
70
+ server_config["headers"] = headers
71
+ if server_data.get("command"):
72
+ server_config["command"] = server_data["command"]
73
+ if args:
74
+ server_config["args"] = args
75
+ if env:
76
+ server_config["env"] = env
77
+
78
+ validated_server = MCPServer.model_validate(server_config)
79
+ validated_servers.append(validated_server)
80
+
81
+ return validated_servers, "ok"
82
+
83
+ except Exception as e:
84
+ return None, f"Validation error: {str(e)}"
@@ -0,0 +1,65 @@
1
+ from idun_agent_schema.engine.observability_v2 import (
2
+ ObservabilityConfig,
3
+ ObservabilityProvider,
4
+ )
5
+
6
+
7
+ def validate_observability(
8
+ provider: ObservabilityProvider, config
9
+ ) -> tuple[ObservabilityConfig | None, str]:
10
+ match provider:
11
+ case ObservabilityProvider.LANGFUSE:
12
+ from idun_agent_schema.engine.observability_v2 import LangfuseConfig
13
+
14
+ try:
15
+ config = LangfuseConfig(**config)
16
+ return ObservabilityConfig(
17
+ provider=provider, config=config, enabled=True
18
+ ), "ok"
19
+ except Exception as e:
20
+ return None, f"Error validating Langfuse config: {e}"
21
+
22
+ case ObservabilityProvider.PHOENIX:
23
+ from idun_agent_schema.engine.observability_v2 import PhoenixConfig
24
+
25
+ try:
26
+ config = PhoenixConfig(**config)
27
+ return ObservabilityConfig(
28
+ provider=provider, config=config, enabled=True
29
+ ), "ok"
30
+ except Exception as e:
31
+ return None, f"Error validating Phoenix config: {e}"
32
+
33
+ case ObservabilityProvider.GCP_LOGGING:
34
+ from idun_agent_schema.engine.observability_v2 import GCPLoggingConfig
35
+
36
+ try:
37
+ config = GCPLoggingConfig(**config)
38
+ return ObservabilityConfig(
39
+ provider=provider, config=config, enabled=True
40
+ ), "ok"
41
+ except Exception as e:
42
+ return None, f"Error validating GCP logging config: {e}"
43
+
44
+ case ObservabilityProvider.GCP_TRACE:
45
+ from idun_agent_schema.engine.observability_v2 import GCPTraceConfig
46
+
47
+ try:
48
+ config = GCPTraceConfig(**config)
49
+ return ObservabilityConfig(
50
+ provider=provider, config=config, enabled=True
51
+ ), "ok"
52
+ except Exception as e:
53
+ return None, f"Error validating GCP trace config: {e}"
54
+
55
+ case ObservabilityProvider.LANGSMITH:
56
+ from idun_agent_schema.engine.observability_v2 import LangsmithConfig
57
+
58
+ try:
59
+ config = LangsmithConfig(**config)
60
+
61
+ return ObservabilityConfig(
62
+ provider=provider, config=config, enabled=True
63
+ ), "ok"
64
+ except Exception as e:
65
+ return None, f"Error validating Langsmith config: {e}"
@@ -0,0 +1,19 @@
1
+ """Widget components for the agent configuration screens."""
2
+
3
+ from .chat_widget import ChatWidget
4
+ from .guardrails_widget import GuardrailsWidget
5
+ from .identity_widget import IdentityWidget
6
+ from .mcps_widget import MCPsWidget
7
+ from .memory_widget import MemoryWidget
8
+ from .observability_widget import ObservabilityWidget
9
+ from .serve_widget import ServeWidget
10
+
11
+ __all__ = [
12
+ "ChatWidget",
13
+ "GuardrailsWidget",
14
+ "IdentityWidget",
15
+ "MCPsWidget",
16
+ "MemoryWidget",
17
+ "ObservabilityWidget",
18
+ "ServeWidget",
19
+ ]
@@ -0,0 +1,153 @@
1
+ """Chat widget for interacting with running agent."""
2
+
3
+ from textual.app import ComposeResult
4
+ from textual.containers import Horizontal, Vertical
5
+ from textual.reactive import reactive
6
+ from textual.widget import Widget
7
+ from textual.widgets import Button, Input, Label, LoadingIndicator, RichLog
8
+
9
+
10
+ class ChatWidget(Widget):
11
+ server_running = reactive(False)
12
+
13
+ def __init__(self, *args, **kwargs):
14
+ super().__init__(*args, **kwargs)
15
+ self.config_data = {}
16
+ self.server_port = None
17
+ self.agent_name = ""
18
+
19
+ def compose(self) -> ComposeResult:
20
+ chat_container = Vertical(classes="chat-history-container")
21
+ chat_container.border_title = "Conversation"
22
+ with chat_container:
23
+ yield RichLog(id="chat_history", highlight=True, markup=True, wrap=True)
24
+
25
+ thinking_container = Horizontal(classes="chat-thinking-container", id="chat_thinking")
26
+ thinking_container.display = False
27
+ with thinking_container:
28
+ yield LoadingIndicator(id="chat_spinner")
29
+ yield Label("Thinking...", id="thinking_label")
30
+
31
+ input_container = Horizontal(classes="chat-input-container")
32
+ with input_container:
33
+ yield Input(
34
+ placeholder="Type your message...",
35
+ id="chat_input",
36
+ classes="chat-input",
37
+ )
38
+ yield Button("Send", id="send_button", classes="send-btn")
39
+
40
+ def load_config(self, config: dict) -> None:
41
+ self.config_data = config
42
+ server_config = config.get("server", {})
43
+ api_config = server_config.get("api", {})
44
+ self.server_port = api_config.get("port", 8008)
45
+
46
+ agent_config = config.get("agent", {}).get("config", {})
47
+ self.agent_name = agent_config.get("name", "Agent")
48
+
49
+ self.run_worker(self._check_server_status())
50
+
51
+ def on_mount(self) -> None:
52
+ chat_log = self.query_one("#chat_history", RichLog)
53
+ chat_log.write("[dim]Start chatting with your agent...[/dim]")
54
+ chat_log.write(
55
+ "[dim]Make sure the agent server is running from the Serve page.[/dim]"
56
+ )
57
+
58
+ def on_button_pressed(self, event: Button.Pressed) -> None:
59
+ if event.button.id == "send_button":
60
+ self._handle_send()
61
+
62
+ def on_input_submitted(self, event: Input.Submitted) -> None:
63
+ if event.input.id == "chat_input":
64
+ self._handle_send()
65
+
66
+ def _handle_send(self) -> None:
67
+ input_widget = self.query_one("#chat_input", Input)
68
+ message = input_widget.value.strip()
69
+
70
+ if not message:
71
+ return
72
+
73
+ if not self.server_port:
74
+ self.app.notify("Server not configured", severity="error")
75
+ return
76
+
77
+ input_widget.value = ""
78
+
79
+ chat_log = self.query_one("#chat_history", RichLog)
80
+ chat_log.write(f"[cyan]You:[/cyan] {message}")
81
+
82
+ thinking_container = self.query_one("#chat_thinking")
83
+ thinking_container.display = True
84
+
85
+ self.run_worker(self._send_message(message))
86
+
87
+ async def _send_message(self, message: str) -> None:
88
+ import httpx
89
+
90
+ chat_log = self.query_one("#chat_history", RichLog)
91
+ thinking_container = self.query_one("#chat_thinking")
92
+
93
+ try:
94
+ url = f"http://localhost:{self.server_port}/agent/invoke"
95
+ async with httpx.AsyncClient(timeout=60.0) as client:
96
+ response = await client.post(
97
+ url, json={"session_id": "123", "query": message}
98
+ )
99
+ result = response.json()
100
+
101
+ agent_response = result.get(
102
+ "output", result.get("response", "No response")
103
+ )
104
+ thinking_container.display = False
105
+ chat_log.write(f"[green]{self.agent_name}:[/green] {agent_response}")
106
+
107
+ except httpx.ConnectError:
108
+ thinking_container.display = False
109
+ chat_log.write("[red]Error:[/red] Cannot connect to server. Is it running?")
110
+ self.app.notify(
111
+ "Server not reachable. Start it from the Serve page.", severity="error"
112
+ )
113
+ except httpx.TimeoutException:
114
+ thinking_container.display = False
115
+ chat_log.write("[red]Error:[/red] Request timed out")
116
+ self.app.notify("Request timed out", severity="error")
117
+ except Exception as e:
118
+ thinking_container.display = False
119
+ chat_log.write(f"[red]Error:[/red] Failed to send message: {e}")
120
+ self.app.notify(
121
+ "Failed to send message. Check server connection.", severity="error"
122
+ )
123
+
124
+ async def _check_server_status(self) -> None:
125
+ import httpx
126
+
127
+ if not self.server_port:
128
+ return
129
+
130
+ try:
131
+ url = f"http://localhost:{self.server_port}/health"
132
+ async with httpx.AsyncClient(timeout=2.0) as client:
133
+ response = await client.get(url)
134
+ self.server_running = response.status_code == 200
135
+
136
+ if self.server_running:
137
+ chat_log = self.query_one("#chat_history", RichLog)
138
+ chat_log.write(
139
+ f"[green]✓ Connected to server on port {self.server_port}[/green]"
140
+ )
141
+ except Exception:
142
+ self.server_running = False
143
+
144
+ def watch_server_running(self, is_running: bool) -> None:
145
+ input_widget = self.query_one("#chat_input", Input)
146
+ send_button = self.query_one("#send_button", Button)
147
+
148
+ if is_running:
149
+ input_widget.disabled = False
150
+ send_button.disabled = False
151
+ else:
152
+ input_widget.disabled = True
153
+ send_button.disabled = True