lang2sim 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. lang2sim/__init__.py +47 -0
  2. lang2sim/agent/__init__.py +5 -0
  3. lang2sim/agent/nemotron_client.py +109 -0
  4. lang2sim/agent/react_loop.py +848 -0
  5. lang2sim/agent/tools.py +998 -0
  6. lang2sim/cli/app.py +183 -0
  7. lang2sim/cli/screens/__init__.py +8 -0
  8. lang2sim/cli/screens/command_palette_modal.py +146 -0
  9. lang2sim/cli/screens/landing_screen.py +96 -0
  10. lang2sim/cli/screens/sessions_modal.py +236 -0
  11. lang2sim/cli/screens/workspace_screen.py +709 -0
  12. lang2sim/cli/widgets/__init__.py +16 -0
  13. lang2sim/cli/widgets/action_pill.py +67 -0
  14. lang2sim/cli/widgets/command_card.py +265 -0
  15. lang2sim/cli/widgets/plan_card.py +106 -0
  16. lang2sim/cli/widgets/prompt_card.py +96 -0
  17. lang2sim/cli/widgets/telemetry_sidebar.py +117 -0
  18. lang2sim/cli/widgets/thought_box.py +64 -0
  19. lang2sim/compiler/__init__.py +15 -0
  20. lang2sim/compiler/sdf_compiler.py +65 -0
  21. lang2sim/compiler/sir_generator.py +46 -0
  22. lang2sim/compiler/sir_validator.py +87 -0
  23. lang2sim/core/__init__.py +50 -0
  24. lang2sim/core/config.py +116 -0
  25. lang2sim/core/models.py +279 -0
  26. lang2sim/core/session_manager.py +189 -0
  27. lang2sim/perturbation/__init__.py +50 -0
  28. lang2sim/perturbation/_compat.py +19 -0
  29. lang2sim/perturbation/gazebo_bridge.py +520 -0
  30. lang2sim/perturbation/mcp_server.py +129 -0
  31. lang2sim/perturbation/routers/__init__.py +19 -0
  32. lang2sim/perturbation/routers/world.py +110 -0
  33. lang2sim/perturbation/sdf_snippets.py +112 -0
  34. lang2sim/runtime/__init__.py +7 -0
  35. lang2sim/runtime/control_api/__init__.py +7 -0
  36. lang2sim/runtime/control_api/client.py +187 -0
  37. lang2sim/runtime/control_api/server.py +504 -0
  38. lang2sim/runtime/display.py +141 -0
  39. lang2sim/runtime/docker_runner.py +286 -0
  40. lang2sim/runtime/doctor.py +220 -0
  41. lang2sim/runtime/hardware_probe.py +144 -0
  42. lang2sim/runtime/health_monitor.py +66 -0
  43. lang2sim/runtime/launch_builder.py +137 -0
  44. lang2sim/runtime/nav2_dispatcher.py +215 -0
  45. lang2sim/runtime/platform.py +137 -0
  46. lang2sim/runtime/ros_bridge.py +183 -0
  47. lang2sim/runtime/run_folder.py +86 -0
  48. lang2sim/runtime/self_healing.py +387 -0
  49. lang2sim/runtime/session.py +136 -0
  50. lang2sim/runtime/world_check.py +197 -0
  51. lang2sim/telemetry/__init__.py +85 -0
  52. lang2sim/telemetry/nqi_scorer.py +645 -0
  53. lang2sim/telemetry/ros_subscribers.py +489 -0
  54. lang2sim/telemetry/trajectory.py +258 -0
  55. lang2sim/telemetry/vision.py +477 -0
  56. lang2sim/world_gen/__init__.py +16 -0
  57. lang2sim/world_gen/catalog_loader.py +83 -0
  58. lang2sim/world_gen/map_slicer.py +131 -0
  59. lang2sim/world_gen/shapely_solver.py +256 -0
  60. lang2sim-2.0.0.dist-info/METADATA +53 -0
  61. lang2sim-2.0.0.dist-info/RECORD +65 -0
  62. lang2sim-2.0.0.dist-info/WHEEL +5 -0
  63. lang2sim-2.0.0.dist-info/entry_points.txt +2 -0
  64. lang2sim-2.0.0.dist-info/licenses/LICENSE +21 -0
  65. lang2sim-2.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,709 @@
1
+ """
2
+ Screen 2: 80/20 Workspace Screen
3
+ File: lang2sim/cli/screens/workspace_screen.py
4
+ """
5
+
6
+ import asyncio
7
+ import json
8
+ from typing import List, Dict, Any, Optional
9
+ from textual.screen import Screen
10
+ from textual.widgets import Static, Markdown
11
+ from textual.containers import Horizontal, Vertical, VerticalScroll
12
+ from textual.binding import Binding
13
+ from textual import work, events
14
+ from ..widgets.prompt_card import PromptCard
15
+ from ..widgets.telemetry_sidebar import TelemetrySidebar
16
+ from ..widgets.thought_box import ThoughtBox
17
+ from ..widgets.action_pill import ActionPill
18
+ from ..widgets.command_card import CommandCard
19
+ from ..widgets.plan_card import PlanCard
20
+ from ...agent.nemotron_client import NemotronClient
21
+ from ...agent.react_loop import (
22
+ SUPERVISOR_SYSTEM_PROMPT,
23
+ ReActSupervisor,
24
+ ThoughtChunk,
25
+ ThoughtCompleted,
26
+ PlanCreated,
27
+ PlanStepUpdated,
28
+ PermissionRequested,
29
+ CommandExecuted,
30
+ ActionStarted,
31
+ ActionCompleted,
32
+ ContentChunk,
33
+ TurnCompleted,
34
+ )
35
+ from ...core.session_manager import session_manager, ChatSession, ChatMessage
36
+
37
+ SYSTEM_PROMPT = SUPERVISOR_SYSTEM_PROMPT
38
+
39
+
40
+ class WorkspaceScreen(Screen):
41
+ """The 80/20 split workspace screen featuring conversational LLM execution and live telemetry."""
42
+
43
+ BINDINGS = [
44
+ Binding("ctrl+s", "open_sessions", "Sessions", show=False),
45
+ ]
46
+
47
+ def __init__(
48
+ self,
49
+ initial_prompt: str = "",
50
+ session_id: Optional[str] = None,
51
+ workspace_name: str = "active_project",
52
+ **kwargs,
53
+ ):
54
+ super().__init__(**kwargs)
55
+ self.initial_prompt = initial_prompt
56
+ self.client = NemotronClient()
57
+ self.workspace_name = workspace_name
58
+ self.always_allow_commands: bool = False
59
+ self._pending_permissions: Dict[str, asyncio.Future] = {}
60
+ self._active_command_cards: Dict[str, CommandCard] = {}
61
+ self._active_plan_card: Optional[PlanCard] = None
62
+ self._selected_text: Optional[str] = None
63
+
64
+ # Session initialization
65
+ if session_id:
66
+ loaded = session_manager.get_session(session_id)
67
+ if loaded:
68
+ self.session = loaded
69
+ else:
70
+ self.session = session_manager.create_session(
71
+ title=initial_prompt[:40] if initial_prompt else "New Session",
72
+ workspace_name=workspace_name,
73
+ system_prompt=SYSTEM_PROMPT,
74
+ )
75
+ else:
76
+ self.session = session_manager.create_session(
77
+ title=initial_prompt[:40] if initial_prompt else "New Session",
78
+ workspace_name=workspace_name,
79
+ system_prompt=SYSTEM_PROMPT,
80
+ )
81
+
82
+ self.conversation_history: List[Dict[str, str]] = []
83
+ for m in self.session.messages:
84
+ if m.role == "system":
85
+ self.conversation_history.append({"role": "system", "content": m.content})
86
+ elif m.role == "user":
87
+ self.conversation_history.append({"role": "user", "content": m.content})
88
+ elif m.role == "assistant":
89
+ self.conversation_history.append({"role": "assistant", "content": m.content})
90
+ self.total_tokens_used: int = self.session.total_tokens
91
+ self.is_streaming: bool = False
92
+
93
+
94
+ def compose(self):
95
+ # Top title bar
96
+ yield Static(f"S2L | {self.session.title} — lang2sim", id="workspace-header")
97
+
98
+ # 80/20 Horizontal body
99
+ with Horizontal(id="workspace-body"):
100
+ # Left Pane: 80% Width
101
+ with Vertical(id="left-pane"):
102
+ with VerticalScroll(id="chat-scroll"):
103
+ yield Static("", id="scroll-anchor")
104
+ yield PromptCard(
105
+ placeholder='lang2sim > Type follow-up (e.g. "drop light to 20%, spawn crate")',
106
+ card_id="sticky-prompt-input",
107
+ id="sticky-input-card",
108
+ )
109
+
110
+ # Right Sidebar: 20% Width
111
+ yield TelemetrySidebar()
112
+
113
+ # Bottom footer bar
114
+ with Horizontal(id="workspace-footer"):
115
+ yield Static("~", classes="footer-left")
116
+ yield Static("", classes="footer-spacer")
117
+ yield Static(
118
+ f"[#ced6e0]{self.total_tokens_used:,} tokens (1%)[/] "
119
+ "[#57606f]ctrl+s sessions[/] [#57606f]ctrl+p commands[/] [#2ed573]●[/] [#57606f]lang2sim 2.0.0[/]",
120
+ id="footer-status",
121
+ classes="footer-right",
122
+ )
123
+
124
+ def on_mount(self) -> None:
125
+ """Mount existing session messages, focus input, and process initial prompt."""
126
+ chat_scroll = self.query_one("#chat-scroll", VerticalScroll)
127
+
128
+ # Restore past messages if loading an existing session
129
+ for msg in self.session.messages:
130
+ if msg.role == "user":
131
+ user_card = Static(f"[bold #ffffff]{msg.content}[/]", classes="user-prompt-card")
132
+ chat_scroll.mount(user_card)
133
+ elif msg.role == "assistant":
134
+ if msg.thought:
135
+ dur = msg.thought_duration_str or "1.2s"
136
+ thought_box = ThoughtBox(duration_str=dur, content=msg.thought)
137
+ chat_scroll.mount(thought_box)
138
+ if msg.actions:
139
+ for act in msg.actions:
140
+ if act.tool_name == "run_command" or (act.arguments and "command" in act.arguments):
141
+ cmd = act.arguments.get("command", "") if act.arguments else ""
142
+ out = act.output.get("stdout", "") if isinstance(act.output, dict) else ""
143
+ err = act.output.get("stderr", "") if isinstance(act.output, dict) else ""
144
+ rc = act.output.get("exit_code", 0) if isinstance(act.output, dict) else 0
145
+ card = CommandCard(
146
+ event_id="restored",
147
+ command=cmd,
148
+ tool_name=act.tool_name,
149
+ arguments=act.arguments,
150
+ permission_required=False,
151
+ )
152
+ chat_scroll.mount(card)
153
+ card.set_result(exit_code=rc, duration_str=act.duration_str, stdout=out, stderr=err)
154
+ else:
155
+ pill = ActionPill(
156
+ tool_name=act.tool_name,
157
+ duration_str=act.duration_str,
158
+ status=act.status,
159
+ )
160
+ chat_scroll.mount(pill)
161
+ resp_md = Markdown(msg.content, classes="assistant-response")
162
+ chat_scroll.mount(resp_md)
163
+
164
+ sidebar = self.query_one(TelemetrySidebar)
165
+ capacity_pct = min(100, max(1, self.total_tokens_used // 1000))
166
+ sidebar.update_telemetry(
167
+ tokens_used=self.total_tokens_used,
168
+ context_capacity_pct=capacity_pct,
169
+ )
170
+
171
+ chat_scroll.scroll_end(animate=False)
172
+ self.query_one("#sticky-input-card", PromptCard).focus_input()
173
+
174
+ # Start periodic telemetry polling from active control server
175
+ self.set_interval(1.5, self.poll_live_telemetry)
176
+
177
+ if self.initial_prompt:
178
+ self.handle_user_prompt(self.initial_prompt)
179
+
180
+ @work(exclusive=True, group="telemetry")
181
+ async def poll_live_telemetry(self) -> None:
182
+ """Polls live Gazebo & ROS 2 telemetry from ControlClient or local container in real time."""
183
+ # 1. Try ControlClient
184
+ try:
185
+ from lang2sim.runtime.control_api.client import ControlClient
186
+ async with ControlClient(timeout=0.3) as client:
187
+ status = await client.sim_status()
188
+ if status.status in ("running", "launching"):
189
+ telem = await client.telemetry()
190
+ pose = telem.robot_pose
191
+ vel = telem.robot_velocity
192
+ rtf_str = f"{telem.real_time_factor:.2f}x" if telem.real_time_factor is not None else "1.0x"
193
+ sidebar = self.query_one(TelemetrySidebar)
194
+ sidebar.update_telemetry(
195
+ sim_state=f"Active ({status.status.capitalize()})",
196
+ sim_time=f"{int(telem.sim_time // 60):02d}:{int(telem.sim_time % 60):02d}",
197
+ rtf=rtf_str,
198
+ robot_x=pose.x if pose else 0.0,
199
+ robot_y=pose.y if pose else 0.0,
200
+ speed=vel.x if vel else 0.0,
201
+ collision_count=0,
202
+ )
203
+ return
204
+ except Exception:
205
+ pass
206
+
207
+ # 2. Inspect Docker container lang2sim-sim or local ROS 2 processes
208
+ try:
209
+ import subprocess
210
+ from datetime import datetime, timezone
211
+
212
+ inspect_proc = subprocess.run(
213
+ "docker inspect lang2sim-sim --format '{{json .State}}'",
214
+ shell=True,
215
+ capture_output=True,
216
+ text=True,
217
+ timeout=1,
218
+ )
219
+ sidebar = self.query_one(TelemetrySidebar)
220
+ if inspect_proc.returncode == 0 and inspect_proc.stdout.strip():
221
+ state_data = json.loads(inspect_proc.stdout)
222
+ is_running = state_data.get("Running", False)
223
+ if is_running:
224
+ started_at = state_data.get("StartedAt", "")
225
+ sim_time_str = "00:00.0"
226
+ if started_at:
227
+ try:
228
+ clean_ts = started_at[:26] + "Z" if "." in started_at else started_at
229
+ start_dt = datetime.fromisoformat(clean_ts.replace("Z", "+00:00"))
230
+ elapsed = (datetime.now(timezone.utc) - start_dt).total_seconds()
231
+ mins = int(elapsed // 60)
232
+ secs = elapsed % 60
233
+ sim_time_str = f"{mins:02d}:{secs:04.1f}"
234
+ except Exception:
235
+ pass
236
+
237
+ # Check if gazebo or rviz2 is running in container
238
+ ps_proc = subprocess.run(
239
+ "docker exec lang2sim-sim ps aux",
240
+ shell=True,
241
+ capture_output=True,
242
+ text=True,
243
+ timeout=1,
244
+ )
245
+ out = ps_proc.stdout.lower()
246
+ has_gui = "rviz2" in out or "gz sim" in out or "gz-sim" in out or "ign gazebo" in out
247
+ sim_state = "Active (GUI)" if has_gui else "Active (Docker)"
248
+ rtf_str = "1.0x" if has_gui else "0.0x"
249
+
250
+ sidebar.update_telemetry(
251
+ sim_state=sim_state,
252
+ sim_time=sim_time_str,
253
+ rtf=rtf_str,
254
+ )
255
+ else:
256
+ sidebar.update_telemetry(
257
+ sim_state="Idle (Offline)",
258
+ sim_time="00:00.0",
259
+ rtf="0.0x",
260
+ )
261
+ else:
262
+ sidebar.update_telemetry(
263
+ sim_state="Idle (Offline)",
264
+ sim_time="00:00.0",
265
+ rtf="0.0x",
266
+ )
267
+ except Exception:
268
+ pass
269
+
270
+ def action_open_sessions(self) -> None:
271
+ """Open custom floating sessions modal."""
272
+ from .sessions_modal import SessionsModal
273
+ self.app.push_screen(
274
+ SessionsModal(workspace_name=self.session.workspace_name),
275
+ callback=self.on_session_switched,
276
+ )
277
+
278
+ def on_session_switched(self, session_id: Optional[str]) -> None:
279
+ if session_id and session_id != self.session.id:
280
+ self.load_session(session_id)
281
+
282
+ def load_session(self, session_id: str) -> None:
283
+ """Load and mount a selected session cleanly."""
284
+ loaded = session_manager.get_session(session_id)
285
+ if not loaded:
286
+ return
287
+
288
+ self.session = loaded
289
+ self.conversation_history = [
290
+ {"role": m.role, "content": m.content} for m in self.session.messages
291
+ ]
292
+ self.total_tokens_used = self.session.total_tokens
293
+
294
+ chat_scroll = self.query_one("#chat-scroll", VerticalScroll)
295
+ chat_scroll.remove_children()
296
+
297
+ for msg in self.session.messages:
298
+ if msg.role == "user":
299
+ user_card = Static(f"[bold #ffffff]{msg.content}[/]", classes="user-prompt-card")
300
+ chat_scroll.mount(user_card)
301
+ elif msg.role == "assistant":
302
+ if msg.thought:
303
+ dur = msg.thought_duration_str or "1.2s"
304
+ thought_box = ThoughtBox(duration_str=dur, content=msg.thought)
305
+ chat_scroll.mount(thought_box)
306
+ if msg.actions:
307
+ for act in msg.actions:
308
+ if act.tool_name == "run_command" or (act.arguments and "command" in act.arguments):
309
+ cmd = act.arguments.get("command", "") if act.arguments else ""
310
+ out = act.output.get("stdout", "") if isinstance(act.output, dict) else ""
311
+ err = act.output.get("stderr", "") if isinstance(act.output, dict) else ""
312
+ rc = act.output.get("exit_code", 0) if isinstance(act.output, dict) else 0
313
+ card = CommandCard(
314
+ event_id="restored",
315
+ command=cmd,
316
+ tool_name=act.tool_name,
317
+ arguments=act.arguments,
318
+ permission_required=False,
319
+ )
320
+ chat_scroll.mount(card)
321
+ card.set_result(exit_code=rc, duration_str=act.duration_str, stdout=out, stderr=err)
322
+ else:
323
+ pill = ActionPill(
324
+ tool_name=act.tool_name,
325
+ duration_str=act.duration_str,
326
+ status=act.status,
327
+ )
328
+ chat_scroll.mount(pill)
329
+ resp_md = Markdown(msg.content, classes="assistant-response")
330
+ chat_scroll.mount(resp_md)
331
+
332
+ # Update header and sidebar
333
+ self.query_one("#workspace-header", Static).update(
334
+ f"S2L | {self.session.title} — lang2sim"
335
+ )
336
+ sidebar = self.query_one(TelemetrySidebar)
337
+ capacity_pct = min(100, max(1, self.total_tokens_used // 1000))
338
+ sidebar.update_telemetry(
339
+ tokens_used=self.total_tokens_used,
340
+ context_capacity_pct=capacity_pct,
341
+ )
342
+
343
+ footer_right = self.query_one("#footer-status", Static)
344
+ if footer_right:
345
+ footer_right.update(
346
+ f"[#ced6e0]{self.total_tokens_used:,} tokens ({capacity_pct}%)[/] "
347
+ "[#57606f]ctrl+s sessions[/] [#57606f]ctrl+p commands[/] [#2ed573]●[/] [#57606f]lang2sim 2.0.0[/]"
348
+ )
349
+
350
+ chat_scroll.scroll_end(animate=False)
351
+ self.query_one("#sticky-input-card", PromptCard).focus_input()
352
+
353
+ def on_prompt_card_submitted(self, message: PromptCard.Submitted) -> None:
354
+ """When user types in sticky input card, add prompt and run execution."""
355
+ self.handle_user_prompt(message.value)
356
+
357
+ def _resolve_first_pending_permission(self, approved: bool, always: bool = False) -> bool:
358
+ """Resolves the oldest pending command permission card."""
359
+ if not self._pending_permissions:
360
+ return False
361
+ if always:
362
+ self.always_allow_commands = True
363
+ event_id, fut = next(iter(self._pending_permissions.items()))
364
+ self._pending_permissions.pop(event_id, None)
365
+ card = self._active_command_cards.get(event_id)
366
+ if card:
367
+ if approved:
368
+ card.set_running()
369
+ else:
370
+ card.status = "rejected"
371
+ card._update_classes()
372
+ card._remove_actions_row()
373
+ try:
374
+ card.query_one("#cmd-badge", Static).update(card._format_badge())
375
+ except Exception:
376
+ pass
377
+ if fut and not fut.done():
378
+ fut.set_result(approved)
379
+ return True
380
+
381
+ def get_conversation_as_text(self) -> str:
382
+ """Assembles entire active chat into clean plain text for copying."""
383
+ lines = []
384
+ for msg in self.session.messages:
385
+ role_label = "USER" if msg.role == "user" else "ASSISTANT"
386
+ lines.append(f"[{role_label}]:\n{msg.content}\n")
387
+ if msg.thought:
388
+ lines.append(f"[THOUGHT]:\n{msg.thought}\n")
389
+ if msg.actions:
390
+ for act in msg.actions:
391
+ lines.append(f"[TOOL: {act.tool_name}]: {act.duration_str} - status: {act.status}")
392
+ return "\n".join(lines).strip()
393
+
394
+ def action_select_all(self) -> None:
395
+ """Select all text in current input or select entire conversation for copying."""
396
+ try:
397
+ from textual.widgets import Input
398
+ inp = self.query_one("#sticky-prompt-input", Input)
399
+ if inp.has_focus and inp.value:
400
+ inp.action_select_all()
401
+ self._selected_text = inp.value
402
+ self.app.notify("Selected input text (Ctrl+Shift+C to copy)", timeout=1.5)
403
+ return
404
+ except Exception:
405
+ pass
406
+
407
+ full_text = self.get_conversation_as_text()
408
+ self._selected_text = full_text
409
+ self.app.notify("Selected all chat text! Press Ctrl+Shift+C to copy", title="Select All", timeout=2.0)
410
+
411
+ def on_key(self, event: events.Key) -> None:
412
+ """Global key interceptor for immediate command approval, Ctrl+A select all, and copy."""
413
+ key = event.key.lower()
414
+
415
+ # 1. Ctrl+A: Select All
416
+ if key == "ctrl+a":
417
+ event.prevent_default()
418
+ event.stop()
419
+ self.action_select_all()
420
+ return
421
+
422
+ # 2. Ctrl+Shift+C / Shift+Ctrl+C: Copy to clipboard
423
+ if key in ("ctrl+shift+c", "shift+ctrl+c"):
424
+ event.prevent_default()
425
+ event.stop()
426
+ self.app.action_copy_selection()
427
+ return
428
+
429
+ # 3. Pending permission card hotkeys
430
+ if self._pending_permissions:
431
+ try:
432
+ from textual.widgets import Input
433
+ inp = self.query_one("#sticky-prompt-input", Input)
434
+ if not inp.value.strip():
435
+ if key in ("y", "enter"):
436
+ event.prevent_default()
437
+ event.stop()
438
+ self._resolve_first_pending_permission(approved=True, always=False)
439
+ return
440
+ elif key == "a":
441
+ event.prevent_default()
442
+ event.stop()
443
+ self._resolve_first_pending_permission(approved=True, always=True)
444
+ return
445
+ elif key in ("n", "escape"):
446
+ event.prevent_default()
447
+ event.stop()
448
+ self._resolve_first_pending_permission(approved=False, always=False)
449
+ return
450
+ except Exception:
451
+ pass
452
+
453
+ def on_prompt_card_submitted(self, message: PromptCard.Submitted) -> None:
454
+ """When user types in sticky input card, add prompt and run execution."""
455
+ self.handle_user_prompt(message.value)
456
+
457
+ def handle_user_prompt(self, prompt: str) -> None:
458
+ """Mount user message and launch async LLM streaming worker."""
459
+ cleaned_prompt = prompt.strip()
460
+ if not cleaned_prompt:
461
+ return
462
+
463
+ # 1. Natural language permission approval routing
464
+ if self._pending_permissions:
465
+ lowered = cleaned_prompt.lower()
466
+ if lowered in ("y", "yes", "run", "approve", "ok", "proceed", "go"):
467
+ self._resolve_first_pending_permission(approved=True, always=False)
468
+ return
469
+ elif lowered in ("a", "always", "always allow", "always_allow"):
470
+ self._resolve_first_pending_permission(approved=True, always=True)
471
+ return
472
+ elif lowered in ("n", "no", "reject", "cancel", "stop", "abort"):
473
+ self._resolve_first_pending_permission(approved=False, always=False)
474
+ return
475
+
476
+ # 2. Recovery for "try again" / "retry" / "restart"
477
+ if cleaned_prompt.lower() in ("try again", "retry", "restart"):
478
+ for event_id, fut in list(self._pending_permissions.items()):
479
+ if not fut.done():
480
+ fut.cancel()
481
+ self._pending_permissions.clear()
482
+ self.is_streaming = False
483
+
484
+ if self.is_streaming:
485
+ return
486
+
487
+ chat_scroll = self.query_one("#chat-scroll", VerticalScroll)
488
+
489
+ # Auto-name session if it is still "New Session"
490
+ if self.session.title == "New Session":
491
+ self.session.title = cleaned_prompt[:40]
492
+ self.query_one("#workspace-header", Static).update(
493
+ f"S2L | {self.session.title} — lang2sim"
494
+ )
495
+
496
+ # Mount user prompt card
497
+ user_card = Static(f"[bold #ffffff]{cleaned_prompt}[/]", classes="user-prompt-card")
498
+ chat_scroll.mount(user_card)
499
+ chat_scroll.scroll_end(animate=False)
500
+
501
+ prompt_tokens = max(1, len(cleaned_prompt) // 4)
502
+ self.session.messages.append(
503
+ ChatMessage(role="user", content=cleaned_prompt, tokens=prompt_tokens)
504
+ )
505
+ self.conversation_history.append({"role": "user", "content": cleaned_prompt})
506
+ session_manager.save_session(self.session)
507
+
508
+ # Mount streaming assistant Markdown widget
509
+ response_widget = Markdown("● *Thinking...*", classes="assistant-response")
510
+ chat_scroll.mount(response_widget)
511
+ chat_scroll.scroll_end(animate=False)
512
+
513
+ # Stream response asynchronously with low latency
514
+ self.stream_response(response_widget, cleaned_prompt)
515
+
516
+ def on_command_card_approved(self, message: CommandCard.Approved) -> None:
517
+ """User approved command execution."""
518
+ if message.always_allow:
519
+ self.always_allow_commands = True
520
+ fut = self._pending_permissions.pop(message.event_id, None)
521
+ if fut and not fut.done():
522
+ fut.set_result(True)
523
+
524
+ def on_command_card_rejected(self, message: CommandCard.Rejected) -> None:
525
+ """User rejected command execution."""
526
+ fut = self._pending_permissions.pop(message.event_id, None)
527
+ if fut and not fut.done():
528
+ fut.set_result(False)
529
+
530
+ @work(exclusive=True, group="chat_stream")
531
+ async def stream_response(self, response_widget: Markdown, prompt: str) -> None:
532
+ """Stream conversational response directly from LLM / ReAct supervisor with low latency."""
533
+ self.is_streaming = True
534
+ chat_scroll = self.query_one("#chat-scroll", VerticalScroll)
535
+ sidebar = self.query_one(TelemetrySidebar)
536
+ sticky_card = self.query_one("#sticky-input-card", PromptCard)
537
+
538
+ if not self.client.is_configured():
539
+ await response_widget.update(
540
+ "**● API Key Required**\n\n"
541
+ "Please configure your API key to chat with Sim2Lang:\n"
542
+ "- Run: `export NVIDIA_API_KEY='nvapi-...'`\n"
543
+ "- Or set `OPENAI_API_KEY='sk-...'`\n"
544
+ "- Or add `NVIDIA_API_KEY=...` to `.env` in the project root."
545
+ )
546
+ self.is_streaming = False
547
+ sticky_card.focus_input()
548
+ return
549
+
550
+ supervisor = ReActSupervisor(client=self.client, workspace_name=self.workspace_name)
551
+ thought_box: Optional[ThoughtBox] = None
552
+ thought_text_parts: List[str] = []
553
+ content_parts: List[str] = []
554
+ active_pills: Dict[str, ActionPill] = {}
555
+
556
+ async def permission_checker(tool_name: str, cmd_str: str, args: dict, event_id: str) -> bool:
557
+ if self.always_allow_commands:
558
+ card = CommandCard(
559
+ event_id=event_id,
560
+ command=cmd_str,
561
+ tool_name=tool_name,
562
+ arguments=args,
563
+ permission_required=False,
564
+ )
565
+ self._active_command_cards[event_id] = card
566
+ await chat_scroll.mount(card, before=response_widget)
567
+ chat_scroll.scroll_end(animate=False)
568
+ return True
569
+
570
+ card = CommandCard(
571
+ event_id=event_id,
572
+ command=cmd_str,
573
+ tool_name=tool_name,
574
+ arguments=args,
575
+ permission_required=True,
576
+ )
577
+ self._active_command_cards[event_id] = card
578
+ await chat_scroll.mount(card, before=response_widget)
579
+ chat_scroll.scroll_end(animate=False)
580
+
581
+ fut = asyncio.get_event_loop().create_future()
582
+ self._pending_permissions[event_id] = fut
583
+ approved = await fut
584
+ return approved
585
+
586
+ try:
587
+ async for event in supervisor.stream_turn(
588
+ self.session,
589
+ prompt,
590
+ self.conversation_history,
591
+ permission_checker=permission_checker,
592
+ ):
593
+ if isinstance(event, ThoughtChunk):
594
+ if not thought_box:
595
+ thought_box = ThoughtBox(duration_str="...", content="")
596
+ await chat_scroll.mount(thought_box, before=response_widget)
597
+ chat_scroll.scroll_end(animate=False)
598
+ thought_text_parts.append(event.chunk)
599
+ thought_box.update_content("".join(thought_text_parts))
600
+
601
+ elif isinstance(event, ThoughtCompleted):
602
+ if not thought_box:
603
+ thought_box = ThoughtBox(duration_str=event.duration_str, content=event.thought)
604
+ await chat_scroll.mount(thought_box, before=response_widget)
605
+ else:
606
+ thought_box.update_content(event.thought, duration_str=event.duration_str)
607
+ chat_scroll.scroll_end(animate=False)
608
+
609
+ elif isinstance(event, PlanCreated):
610
+ self._active_plan_card = PlanCard(steps=event.steps, title=event.title)
611
+ await chat_scroll.mount(self._active_plan_card, before=response_widget)
612
+ chat_scroll.scroll_end(animate=False)
613
+
614
+ elif isinstance(event, PlanStepUpdated):
615
+ if self._active_plan_card:
616
+ self._active_plan_card.update_step(event.step_index, event.status, event.detail)
617
+ chat_scroll.scroll_end(animate=False)
618
+
619
+ elif isinstance(event, PermissionRequested):
620
+ chat_scroll.scroll_end(animate=False)
621
+
622
+ elif isinstance(event, CommandExecuted):
623
+ card = self._active_command_cards.get(event.event_id)
624
+ if card:
625
+ card.set_result(
626
+ exit_code=event.exit_code,
627
+ duration_str=event.duration_str,
628
+ stdout=event.stdout,
629
+ stderr=event.stderr,
630
+ )
631
+ chat_scroll.scroll_end(animate=False)
632
+
633
+ elif isinstance(event, ActionStarted):
634
+ if event.tool_name != "run_command" and self._active_plan_card is None:
635
+ pill = ActionPill(tool_name=event.tool_name, duration_str="0.0s", status="running")
636
+ active_pills[event.tool_name] = pill
637
+ await chat_scroll.mount(pill, before=response_widget)
638
+ chat_scroll.scroll_end(animate=False)
639
+
640
+ elif isinstance(event, ActionCompleted):
641
+ pill = active_pills.get(event.tool_name)
642
+ if pill:
643
+ pill.set_completed(event.duration_str)
644
+ elif event.tool_name != "run_command" and self._active_plan_card is None:
645
+ pill = ActionPill(tool_name=event.tool_name, duration_str=event.duration_str, status=event.status)
646
+ await chat_scroll.mount(pill, before=response_widget)
647
+
648
+ # Dynamically sync live telemetry metrics into sidebar
649
+ if event.tool_name == "query_robot_telemetry" and isinstance(event.output, dict):
650
+ sidebar = self.query_one(TelemetrySidebar)
651
+ sidebar.update_telemetry(
652
+ robot_x=float(event.output.get("x", 0.0)),
653
+ robot_y=float(event.output.get("y", 0.0)),
654
+ speed=float(event.output.get("speed", 0.0)),
655
+ clearance=float(event.output.get("clearance", 0.0)),
656
+ bumper_force=float(event.output.get("bumper_force_newtons", 0.0)),
657
+ collision_count=int(event.output.get("collision_count", 0)),
658
+ )
659
+ chat_scroll.scroll_end(animate=False)
660
+
661
+ elif isinstance(event, ContentChunk):
662
+ content_parts.append(event.chunk)
663
+ await response_widget.update("".join(content_parts))
664
+ chat_scroll.scroll_end(animate=False)
665
+
666
+ elif isinstance(event, TurnCompleted):
667
+ full_text = event.full_content
668
+ if not full_text:
669
+ full_text = "".join(content_parts).strip()
670
+ if full_text:
671
+ await response_widget.update(full_text)
672
+ history_content = full_text
673
+ self.conversation_history.append({"role": "assistant", "content": full_text})
674
+ prompt_tokens = max(1, len(prompt) // 4)
675
+ resp_tokens = max(1, len(full_text) // 4)
676
+ self.total_tokens_used += prompt_tokens + resp_tokens
677
+ capacity_pct = min(100, max(1, self.total_tokens_used // 1000))
678
+
679
+ self.session.messages.append(
680
+ ChatMessage(
681
+ role="assistant",
682
+ content=full_text,
683
+ thought=event.thought,
684
+ thought_duration_str=event.thought_duration_str,
685
+ actions=event.actions,
686
+ tokens=resp_tokens,
687
+ )
688
+ )
689
+ self.session.total_tokens = self.total_tokens_used
690
+ session_manager.save_session(self.session)
691
+
692
+ sidebar.update_telemetry(
693
+ tokens_used=self.total_tokens_used,
694
+ context_capacity_pct=capacity_pct,
695
+ )
696
+
697
+ footer_right = self.query_one("#footer-status", Static)
698
+ if footer_right:
699
+ footer_right.update(
700
+ f"[#ced6e0]{self.total_tokens_used:,} tokens ({capacity_pct}%)[/] "
701
+ "[#57606f]ctrl+s sessions[/] [#57606f]ctrl+p commands[/] [#2ed573]●[/] [#57606f]lang2sim 2.0.0[/]"
702
+ )
703
+ else:
704
+ await response_widget.update("*(No response received from model)*")
705
+ except Exception as e:
706
+ await response_widget.update(f"[bold #ef4444]⚠ Connection Error:[/] {str(e)}")
707
+ finally:
708
+ self.is_streaming = False
709
+ sticky_card.focus_input()