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,622 @@
1
+ from pathlib import Path
2
+
3
+ from idun_agent_schema.engine.guardrails_v2 import GuardrailsV2
4
+ from idun_agent_schema.engine.observability_v2 import ObservabilityConfig
5
+ from pydantic import ValidationError
6
+ from textual.app import ComposeResult
7
+ from textual.containers import Container, Horizontal, Vertical
8
+ from textual.reactive import reactive
9
+ from textual.screen import Screen
10
+ from textual.widgets import Button, Footer, Label, RichLog, Static
11
+
12
+ from idun_platform_cli.tui.css.create_agent import CREATE_AGENT_CSS
13
+ from idun_platform_cli.tui.schemas.create_agent import TUIAgentConfig
14
+ from idun_platform_cli.tui.utils.config import ConfigManager
15
+ from idun_platform_cli.tui.widgets import (
16
+ ChatWidget,
17
+ GuardrailsWidget,
18
+ IdentityWidget,
19
+ MCPsWidget,
20
+ MemoryWidget,
21
+ ObservabilityWidget,
22
+ ServeWidget,
23
+ )
24
+
25
+
26
+ class CreateAgentScreen(Screen):
27
+ CSS = CREATE_AGENT_CSS
28
+ BINDINGS = [
29
+ ("tab", "toggle_focus_area", "Switch Area / Next Field"),
30
+ ("up", "nav_up", "Navigate Panes"),
31
+ ("down", "nav_down", ""),
32
+ ]
33
+
34
+ active_section = reactive("identity")
35
+ nav_panes = [
36
+ "nav-identity",
37
+ "nav-memory",
38
+ "nav-observability",
39
+ "nav-guardrails",
40
+ "nav-mcps",
41
+ "nav-serve",
42
+ "nav-chat",
43
+ ]
44
+ current_nav_index = 0
45
+ focus_on_nav = True # Track if focus is on nav or content
46
+
47
+ def __init__(self, *args, **kwargs):
48
+ super().__init__(*args, **kwargs)
49
+ self.widgets_map = {}
50
+ self.config_manager = ConfigManager()
51
+ self.validated_sections = set()
52
+ self.server_process = None
53
+ self.server_running = False
54
+
55
+ def on_unmount(self) -> None:
56
+ if self.server_process:
57
+ import os
58
+ import signal
59
+ import subprocess
60
+
61
+ try:
62
+ pgid = os.getpgid(self.server_process.pid)
63
+ os.killpg(pgid, signal.SIGKILL)
64
+ except (ProcessLookupError, OSError, AttributeError, PermissionError):
65
+ try:
66
+ self.server_process.kill()
67
+ except:
68
+ pass
69
+
70
+ try:
71
+ config = self.config_manager.load_config()
72
+ if config:
73
+ port = config.get("server", {}).get("api", {}).get("port", 8008)
74
+ pids = subprocess.check_output(["lsof", "-ti", f":{port}"], text=True).strip().split("\n")
75
+ for pid in pids:
76
+ if pid:
77
+ try:
78
+ os.kill(int(pid), signal.SIGKILL)
79
+ except:
80
+ pass
81
+ except:
82
+ pass
83
+
84
+ def watch_active_section(self, new_section: str) -> None:
85
+ for section_id, widget in self.widgets_map.items():
86
+ if section_id == new_section:
87
+ widget.display = True
88
+ else:
89
+ widget.display = False
90
+
91
+ if new_section == "serve":
92
+ config = self.config_manager.load_config()
93
+ serve_widget = self.widgets_map.get("serve")
94
+ if serve_widget:
95
+ if config:
96
+ serve_widget.load_config(config)
97
+ else:
98
+ self.notify(
99
+ "Debug: Config is empty, agent_path might not be set",
100
+ severity="warning",
101
+ )
102
+
103
+ if new_section == "chat":
104
+ config = self.config_manager.load_config()
105
+ chat_widget = self.widgets_map.get("chat")
106
+ if chat_widget and config:
107
+ chat_widget.load_config(config)
108
+
109
+ for pane_id in [
110
+ "nav-identity",
111
+ "nav-memory",
112
+ "nav-observability",
113
+ "nav-guardrails",
114
+ "nav-mcps",
115
+ "nav-serve",
116
+ "nav-chat",
117
+ ]:
118
+ pane = self.query_one(f"#{pane_id}")
119
+ if pane_id == f"nav-{new_section}":
120
+ pane.add_class("nav-pane-active")
121
+ else:
122
+ pane.remove_class("nav-pane-active")
123
+
124
+ def compose(self) -> ComposeResult:
125
+ app_container = Container(classes="app-container")
126
+ app_container.border_title = "Creating a New Agent"
127
+
128
+ with app_container, Horizontal(classes="main-layout"):
129
+ nav_container = Vertical(classes="nav-container")
130
+ nav_container.border_title = "Sections"
131
+
132
+ with nav_container:
133
+ nav_identity = Vertical(
134
+ Label(
135
+ "Configure agent\nname, framework,\nand port",
136
+ id="nav-identity-label",
137
+ ),
138
+ classes="nav-pane nav-pane-active",
139
+ id="nav-identity",
140
+ )
141
+ nav_identity.border_title = "Agent Information"
142
+ nav_identity.can_focus = True
143
+ yield nav_identity
144
+
145
+ nav_memory = Vertical(
146
+ Label(
147
+ "Configure agent\ncheckpointing",
148
+ id="nav-memory-label",
149
+ ),
150
+ classes="nav-pane",
151
+ id="nav-memory",
152
+ )
153
+ nav_memory.border_title = "Memory"
154
+ nav_memory.can_focus = True
155
+ yield nav_memory
156
+
157
+ nav_observability = Vertical(
158
+ Label(
159
+ "Setup monitoring\nand tracing",
160
+ id="nav-observability-label",
161
+ ),
162
+ classes="nav-pane",
163
+ id="nav-observability",
164
+ )
165
+ nav_observability.border_title = "Observability"
166
+ nav_observability.can_focus = True
167
+ yield nav_observability
168
+
169
+ nav_guardrails = Vertical(
170
+ Label("Define rules and\nvalidation"),
171
+ classes="nav-pane",
172
+ id="nav-guardrails",
173
+ )
174
+ nav_guardrails.border_title = "Guardrails"
175
+ nav_guardrails.can_focus = True
176
+ yield nav_guardrails
177
+
178
+ nav_mcps = Vertical(
179
+ Label("Add tools and\nresources"),
180
+ classes="nav-pane",
181
+ id="nav-mcps",
182
+ )
183
+ nav_mcps.border_title = "MCPs"
184
+ nav_mcps.can_focus = True
185
+ yield nav_mcps
186
+
187
+ nav_serve = Vertical(
188
+ Label("Review and start\nagent"),
189
+ classes="nav-pane",
190
+ id="nav-serve",
191
+ )
192
+ nav_serve.border_title = "Validate & Run"
193
+ nav_serve.can_focus = True
194
+ yield nav_serve
195
+
196
+ nav_chat = Vertical(
197
+ Label("Chat with your\nrunning agent"),
198
+ classes="nav-pane",
199
+ id="nav-chat",
200
+ )
201
+ nav_chat.border_title = "Chat"
202
+ nav_chat.can_focus = True
203
+ yield nav_chat
204
+
205
+ with Horizontal(classes="action-buttons"):
206
+ yield Button("Back", id="back_button", classes="action-btn")
207
+ yield Button("Next", id="next_button", classes="action-btn")
208
+
209
+ with Vertical(classes="content-area"):
210
+ identity = IdentityWidget(id="widget-identity", classes="section")
211
+
212
+ memory = MemoryWidget(id="widget-memory", classes="section")
213
+ memory.border_title = "Memory & Checkpointing"
214
+
215
+ observability = ObservabilityWidget(
216
+ id="widget-observability", classes="section"
217
+ )
218
+ observability.border_title = "Observability"
219
+
220
+ guardrails = GuardrailsWidget(id="widget-guardrails", classes="section")
221
+ guardrails.border_title = "Guardrails"
222
+
223
+ mcps = MCPsWidget(id="widget-mcps", classes="section")
224
+ mcps.border_title = "MCPs"
225
+
226
+ serve = ServeWidget(id="widget-serve", classes="section")
227
+ serve.border_title = "Validate & Run"
228
+
229
+ chat = ChatWidget(id="widget-chat", classes="section")
230
+ chat.border_title = "Chat"
231
+
232
+ self.widgets_map = {
233
+ "identity": identity,
234
+ "memory": memory,
235
+ "observability": observability,
236
+ "guardrails": guardrails,
237
+ "mcps": mcps,
238
+ "serve": serve,
239
+ "chat": chat,
240
+ }
241
+
242
+ memory.display = False
243
+ observability.display = False
244
+ guardrails.display = False
245
+ mcps.display = False
246
+ serve.display = False
247
+ chat.display = False
248
+
249
+ yield identity
250
+ yield memory
251
+ yield observability
252
+ yield guardrails
253
+ yield mcps
254
+ yield serve
255
+ yield chat
256
+
257
+ footer = Static("💡 Press Next to save section | Press Ctrl+Q to exit", classes="custom-footer")
258
+ yield footer
259
+
260
+ def on_mount(self) -> None:
261
+ nav_pane = self.query_one("#nav-identity")
262
+ nav_pane.focus()
263
+
264
+ def action_toggle_focus_area(self) -> None:
265
+ focused = self.focused
266
+
267
+ if (
268
+ focused
269
+ and hasattr(focused, "id")
270
+ and focused.id
271
+ and focused.id.startswith("nav-")
272
+ ):
273
+ self.focus_on_nav = False
274
+ active_widget = self.widgets_map.get(self.active_section)
275
+ if active_widget:
276
+ try:
277
+ focusable = active_widget.query(
278
+ "Input, OptionList, DirectoryTree, Button, RadioSet"
279
+ ).first()
280
+ if focusable:
281
+ focusable.focus()
282
+ else:
283
+ active_widget.focus()
284
+ except:
285
+ active_widget.focus()
286
+ else:
287
+ self.focus_next()
288
+
289
+ def on_focus(self, event) -> None:
290
+ focused_widget = event.widget
291
+ nav_container = self.query_one(".nav-container")
292
+
293
+ if focused_widget.id and focused_widget.id.startswith("nav-"):
294
+ self.focus_on_nav = True
295
+ nav_container.add_class("nav-container-active")
296
+ else:
297
+ nav_container.remove_class("nav-container-active")
298
+ if focused_widget.id in [
299
+ "name_input",
300
+ "framework_select",
301
+ "port_input",
302
+ "file_tree",
303
+ "variable_list",
304
+ ]:
305
+ self.focus_on_nav = False
306
+
307
+ def action_nav_up(self) -> None:
308
+ focused = self.focused
309
+ if (
310
+ focused
311
+ and hasattr(focused, "id")
312
+ and focused.id
313
+ and focused.id.startswith("nav-")
314
+ ):
315
+ self.current_nav_index = (self.current_nav_index - 1) % len(self.nav_panes)
316
+ nav_pane = self.query_one(f"#{self.nav_panes[self.current_nav_index]}")
317
+ nav_pane.focus()
318
+ section = self.nav_panes[self.current_nav_index].replace("nav-", "")
319
+ self.active_section = section
320
+
321
+ def action_nav_down(self) -> None:
322
+ focused = self.focused
323
+ if (
324
+ focused
325
+ and hasattr(focused, "id")
326
+ and focused.id
327
+ and focused.id.startswith("nav-")
328
+ ):
329
+ self.current_nav_index = (self.current_nav_index + 1) % len(self.nav_panes)
330
+ nav_pane = self.query_one(f"#{self.nav_panes[self.current_nav_index]}")
331
+ nav_pane.focus()
332
+ section = self.nav_panes[self.current_nav_index].replace("nav-", "")
333
+ self.active_section = section
334
+
335
+ def on_click(self, event) -> None:
336
+ target = event.widget
337
+ for node in [target] + list(target.ancestors):
338
+ if node.id and node.id.startswith("nav-"):
339
+ section = node.id.replace("nav-", "")
340
+ self.active_section = section
341
+ self.focus_on_nav = True
342
+
343
+ nav_id = f"nav-{section}"
344
+ if nav_id in self.nav_panes:
345
+ self.current_nav_index = self.nav_panes.index(nav_id)
346
+ break
347
+
348
+ def on_button_pressed(self, event: Button.Pressed) -> None:
349
+ if event.button.id == "back_button":
350
+ current_index = self.current_nav_index
351
+ if current_index > 0:
352
+ self.current_nav_index = current_index - 1
353
+ nav_pane = self.query_one(f"#{self.nav_panes[self.current_nav_index]}")
354
+ nav_pane.focus()
355
+ section = self.nav_panes[self.current_nav_index].replace("nav-", "")
356
+ self.active_section = section
357
+ elif event.button.id == "next_button":
358
+ section = self.nav_panes[self.current_nav_index].replace("nav-", "")
359
+ widget = self.widgets_map.get(section)
360
+
361
+ if not widget:
362
+ return
363
+
364
+ if section == "identity":
365
+ if not widget.validate():
366
+ return
367
+ data = widget.get_data()
368
+ if data is None:
369
+ self.notify("Please complete all required fields", severity="error")
370
+ return
371
+ try:
372
+ _ = TUIAgentConfig.model_validate(data)
373
+ agent_name = data.get("name")
374
+ success, msg = self.config_manager.save_partial(
375
+ "identity", data, agent_name=agent_name
376
+ )
377
+ if not success:
378
+ error_msg = str(msg).replace("[", "").replace("]", "")
379
+ self.notify(
380
+ f"Save failed: {error_msg[:200]}",
381
+ severity="error",
382
+ )
383
+ return
384
+ self.validated_sections.add("identity")
385
+ self._update_nav_checkmark("identity")
386
+ except ValidationError:
387
+ self.notify(
388
+ "Error validating Identity: make sure all fields are correct.",
389
+ severity="error",
390
+ )
391
+ return
392
+
393
+ elif section == "memory":
394
+ data = widget.get_data()
395
+ if data is not None:
396
+ success, msg = self.config_manager.save_partial("memory", data)
397
+ if not success:
398
+ self.notify(
399
+ "Memory configuration is invalid", severity="error"
400
+ )
401
+ return
402
+ self.validated_sections.add("memory")
403
+ self._update_nav_checkmark("memory")
404
+ else:
405
+ self.notify("Please configure checkpoint settings", severity="error")
406
+ return
407
+
408
+ elif section == "observability":
409
+ data = widget.get_data()
410
+ if data is None:
411
+ self.validated_sections.add("observability")
412
+ self._update_nav_checkmark("observability")
413
+ elif isinstance(data, ObservabilityConfig):
414
+ success, _ = self.config_manager.save_partial("observability", data)
415
+ if not success:
416
+ self.notify(
417
+ "Observability configuration is invalid", severity="error"
418
+ )
419
+ return
420
+ self.validated_sections.add("observability")
421
+ self._update_nav_checkmark("observability")
422
+ else:
423
+ self.notify(
424
+ "Observability configuration is invalid", severity="error"
425
+ )
426
+ return
427
+
428
+ elif section == "guardrails":
429
+ data = widget.get_data()
430
+ if data and isinstance(data, GuardrailsV2):
431
+ success, msg = self.config_manager.save_partial("guardrails", data)
432
+ if not success:
433
+ self.notify(
434
+ "Guardrails configuration is invalid", severity="error"
435
+ )
436
+ return
437
+ self.validated_sections.add("guardrails")
438
+ self._update_nav_checkmark("guardrails")
439
+
440
+ elif section == "mcps":
441
+ from idun_platform_cli.tui.validators.mcps import validate_mcp_servers
442
+
443
+ data = widget.get_data()
444
+ if data is None:
445
+ self.notify("Invalid MCP configuration", severity="error")
446
+ return
447
+
448
+ validated_servers, msg = validate_mcp_servers(data)
449
+ if validated_servers is None:
450
+ self.notify("Error validating MCPs: make sure all fields are correct.", severity="error")
451
+ return
452
+
453
+ success, save_msg = self.config_manager.save_partial(
454
+ "mcp_servers", validated_servers
455
+ )
456
+ if not success:
457
+ self.notify("Error saving MCPs: make sure all fields are correct.", severity="error")
458
+ return
459
+
460
+ self.validated_sections.add("mcps")
461
+ self._update_nav_checkmark("mcps")
462
+
463
+ current_index = self.current_nav_index
464
+ if current_index < len(self.nav_panes) - 1:
465
+ self.current_nav_index = current_index + 1
466
+ nav_pane = self.query_one(f"#{self.nav_panes[self.current_nav_index]}")
467
+ nav_pane.focus()
468
+ section = self.nav_panes[self.current_nav_index].replace("nav-", "")
469
+ self.active_section = section
470
+
471
+ elif event.button.id == "save_exit_button":
472
+ if self.server_running and self.server_process:
473
+ self.notify("Kill Server before exiting", severity="warning")
474
+ return
475
+ self.notify("Configuration saved. Exiting...", severity="information")
476
+ self.app.exit()
477
+
478
+ elif event.button.id == "save_run_button":
479
+ if self.server_running and self.server_process:
480
+ import os
481
+ import signal
482
+ import subprocess
483
+
484
+ rich_log = self.query_one("#server_logs", RichLog)
485
+ rich_log.write("\n[yellow]Stopping server...[/yellow]")
486
+
487
+ try:
488
+ pgid = os.getpgid(self.server_process.pid)
489
+ os.killpg(pgid, signal.SIGTERM)
490
+ try:
491
+ self.server_process.wait(timeout=3)
492
+ except subprocess.TimeoutExpired:
493
+ os.killpg(pgid, signal.SIGKILL)
494
+ self.server_process.wait(timeout=1)
495
+ except (ProcessLookupError, OSError, PermissionError):
496
+ try:
497
+ self.server_process.terminate()
498
+ self.server_process.wait(timeout=2)
499
+ except subprocess.TimeoutExpired:
500
+ self.server_process.kill()
501
+ self.server_process.wait(timeout=1)
502
+ except:
503
+ pass
504
+
505
+ config = self.config_manager.load_config()
506
+ if config:
507
+ port = config.get("server", {}).get("api", {}).get("port", 8008)
508
+ try:
509
+ subprocess.run(
510
+ ["lsof", "-ti", f":{port}"],
511
+ capture_output=True,
512
+ text=True,
513
+ check=True
514
+ )
515
+ pids = subprocess.check_output(["lsof", "-ti", f":{port}"], text=True).strip().split("\n")
516
+ for pid in pids:
517
+ if pid:
518
+ try:
519
+ os.kill(int(pid), signal.SIGKILL)
520
+ except:
521
+ pass
522
+ except:
523
+ pass
524
+
525
+ self.server_process = None
526
+ self.server_running = False
527
+
528
+ button = self.query_one("#save_run_button", Button)
529
+ button.label = "Save and Run"
530
+ button.remove_class("kill-mode")
531
+
532
+ rich_log.write("\n[red]Server stopped[/red]")
533
+ self.notify("Server stopped", severity="information")
534
+ return
535
+
536
+ serve_widget = self.widgets_map.get("serve")
537
+ if not serve_widget:
538
+ return
539
+
540
+ agent_name = serve_widget.get_agent_name()
541
+ if not agent_name:
542
+ self.notify("No agent configuration found", severity="error")
543
+ return
544
+
545
+ sanitized_name = self.config_manager._sanitize_agent_name(agent_name)
546
+ config_path = Path.home() / ".idun" / f"{sanitized_name}.yaml"
547
+
548
+ if not config_path.exists():
549
+ self.notify("Configuration file not found", severity="error")
550
+ return
551
+
552
+ logs_container = self.query_one("#logs_container")
553
+ logs_container.display = True
554
+
555
+ rich_log = self.query_one("#server_logs", RichLog)
556
+ rich_log.clear()
557
+ rich_log.write(f"Starting server for agent: {agent_name}")
558
+ rich_log.write(f"Config: {config_path}")
559
+
560
+ import fcntl
561
+ import os
562
+ import subprocess
563
+
564
+ try:
565
+ process = subprocess.Popen(
566
+ ["idun", "agent", "serve", "--source=file", f"--path={config_path}"],
567
+ stdout=subprocess.PIPE,
568
+ stderr=subprocess.STDOUT,
569
+ text=True,
570
+ bufsize=1,
571
+ start_new_session=True,
572
+ )
573
+
574
+ fd = process.stdout.fileno()
575
+ fl = fcntl.fcntl(fd, fcntl.F_GETFL)
576
+ fcntl.fcntl(fd, fcntl.F_SETFL, fl | os.O_NONBLOCK)
577
+
578
+ self.server_process = process
579
+ self.server_running = True
580
+
581
+ button = self.query_one("#save_run_button", Button)
582
+ button.label = "Kill Server"
583
+ button.add_class("kill-mode")
584
+
585
+ self.run_worker(self._stream_logs(process), exclusive=True)
586
+
587
+ except Exception:
588
+ self.notify("Failed to start server. Check your configuration.", severity="error")
589
+
590
+ async def _stream_logs(self, process) -> None:
591
+ import asyncio
592
+
593
+ rich_log = self.query_one("#server_logs", RichLog)
594
+
595
+ while self.server_running:
596
+ try:
597
+ line = process.stdout.readline()
598
+ if line:
599
+ rich_log.write(line.strip())
600
+ except:
601
+ pass
602
+
603
+ if process.poll() is not None:
604
+ remaining = process.stdout.read()
605
+ if remaining:
606
+ for line in remaining.split("\n"):
607
+ if line.strip():
608
+ rich_log.write(line.strip())
609
+
610
+ self.server_running = False
611
+ self.server_process = None
612
+ button = self.query_one("#save_run_button", Button)
613
+ button.label = "Save and Run"
614
+ button.remove_class("kill-mode")
615
+ rich_log.write("\n[yellow]Server exited[/yellow]")
616
+ break
617
+
618
+ await asyncio.sleep(0.1)
619
+
620
+ def _update_nav_checkmark(self, section: str) -> None:
621
+ nav_pane = self.query_one(f"#nav-{section}")
622
+ nav_pane.add_class("nav-pane-validated")
File without changes