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.
- lang2sim/__init__.py +47 -0
- lang2sim/agent/__init__.py +5 -0
- lang2sim/agent/nemotron_client.py +109 -0
- lang2sim/agent/react_loop.py +848 -0
- lang2sim/agent/tools.py +998 -0
- lang2sim/cli/app.py +183 -0
- lang2sim/cli/screens/__init__.py +8 -0
- lang2sim/cli/screens/command_palette_modal.py +146 -0
- lang2sim/cli/screens/landing_screen.py +96 -0
- lang2sim/cli/screens/sessions_modal.py +236 -0
- lang2sim/cli/screens/workspace_screen.py +709 -0
- lang2sim/cli/widgets/__init__.py +16 -0
- lang2sim/cli/widgets/action_pill.py +67 -0
- lang2sim/cli/widgets/command_card.py +265 -0
- lang2sim/cli/widgets/plan_card.py +106 -0
- lang2sim/cli/widgets/prompt_card.py +96 -0
- lang2sim/cli/widgets/telemetry_sidebar.py +117 -0
- lang2sim/cli/widgets/thought_box.py +64 -0
- lang2sim/compiler/__init__.py +15 -0
- lang2sim/compiler/sdf_compiler.py +65 -0
- lang2sim/compiler/sir_generator.py +46 -0
- lang2sim/compiler/sir_validator.py +87 -0
- lang2sim/core/__init__.py +50 -0
- lang2sim/core/config.py +116 -0
- lang2sim/core/models.py +279 -0
- lang2sim/core/session_manager.py +189 -0
- lang2sim/perturbation/__init__.py +50 -0
- lang2sim/perturbation/_compat.py +19 -0
- lang2sim/perturbation/gazebo_bridge.py +520 -0
- lang2sim/perturbation/mcp_server.py +129 -0
- lang2sim/perturbation/routers/__init__.py +19 -0
- lang2sim/perturbation/routers/world.py +110 -0
- lang2sim/perturbation/sdf_snippets.py +112 -0
- lang2sim/runtime/__init__.py +7 -0
- lang2sim/runtime/control_api/__init__.py +7 -0
- lang2sim/runtime/control_api/client.py +187 -0
- lang2sim/runtime/control_api/server.py +504 -0
- lang2sim/runtime/display.py +141 -0
- lang2sim/runtime/docker_runner.py +286 -0
- lang2sim/runtime/doctor.py +220 -0
- lang2sim/runtime/hardware_probe.py +144 -0
- lang2sim/runtime/health_monitor.py +66 -0
- lang2sim/runtime/launch_builder.py +137 -0
- lang2sim/runtime/nav2_dispatcher.py +215 -0
- lang2sim/runtime/platform.py +137 -0
- lang2sim/runtime/ros_bridge.py +183 -0
- lang2sim/runtime/run_folder.py +86 -0
- lang2sim/runtime/self_healing.py +387 -0
- lang2sim/runtime/session.py +136 -0
- lang2sim/runtime/world_check.py +197 -0
- lang2sim/telemetry/__init__.py +85 -0
- lang2sim/telemetry/nqi_scorer.py +645 -0
- lang2sim/telemetry/ros_subscribers.py +489 -0
- lang2sim/telemetry/trajectory.py +258 -0
- lang2sim/telemetry/vision.py +477 -0
- lang2sim/world_gen/__init__.py +16 -0
- lang2sim/world_gen/catalog_loader.py +83 -0
- lang2sim/world_gen/map_slicer.py +131 -0
- lang2sim/world_gen/shapely_solver.py +256 -0
- lang2sim-2.0.0.dist-info/METADATA +53 -0
- lang2sim-2.0.0.dist-info/RECORD +65 -0
- lang2sim-2.0.0.dist-info/WHEEL +5 -0
- lang2sim-2.0.0.dist-info/entry_points.txt +2 -0
- lang2sim-2.0.0.dist-info/licenses/LICENSE +21 -0
- 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()
|