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
lang2sim/cli/app.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Main Textual Application for Lang2Sim
|
|
3
|
+
File: lang2sim/cli/app.py
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from textual.app import App
|
|
8
|
+
from textual.binding import Binding
|
|
9
|
+
from .screens.landing_screen import LandingScreen
|
|
10
|
+
|
|
11
|
+
CSS_PATH = Path(__file__).parent / "styles" / "app.tcss"
|
|
12
|
+
|
|
13
|
+
class Lang2SimApp(App):
|
|
14
|
+
"""Lang2Sim Terminal Copilot Application."""
|
|
15
|
+
|
|
16
|
+
TITLE = "lang2sim — lang2sim"
|
|
17
|
+
CSS_PATH = CSS_PATH
|
|
18
|
+
|
|
19
|
+
BINDINGS = [
|
|
20
|
+
Binding("ctrl+a", "select_all", "Select All", show=False),
|
|
21
|
+
Binding("ctrl+c", "copy_or_quit", "Copy / Quit", show=False),
|
|
22
|
+
Binding("ctrl+shift+c", "copy_selection", "Copy Selection", show=False),
|
|
23
|
+
Binding("shift+ctrl+c", "copy_selection", "Copy Selection", show=False),
|
|
24
|
+
Binding("ctrl+y", "copy_selection", "Copy Selection", show=False),
|
|
25
|
+
Binding("ctrl+p", "command_palette", "Command Palette", show=False),
|
|
26
|
+
Binding("ctrl+s", "open_sessions", "Sessions", show=False),
|
|
27
|
+
Binding("ctrl+n", "new_session", "New Session", show=False),
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
def action_select_all(self) -> None:
|
|
31
|
+
"""Selects all text in focused input or selects the entire conversation for copying."""
|
|
32
|
+
if hasattr(self.screen, "action_select_all"):
|
|
33
|
+
self.screen.action_select_all()
|
|
34
|
+
else:
|
|
35
|
+
focused = self.screen.focused
|
|
36
|
+
if focused is not None and hasattr(focused, "action_select_all"):
|
|
37
|
+
focused.action_select_all()
|
|
38
|
+
|
|
39
|
+
def action_copy_selection(self) -> None:
|
|
40
|
+
"""Copies currently selected text or entire conversation to system clipboard via OSC 52."""
|
|
41
|
+
import base64
|
|
42
|
+
import sys
|
|
43
|
+
from pathlib import Path
|
|
44
|
+
|
|
45
|
+
text = ""
|
|
46
|
+
if hasattr(self.screen, "_selected_text") and self.screen._selected_text:
|
|
47
|
+
text = self.screen._selected_text
|
|
48
|
+
elif hasattr(self.screen, "get_selected_text"):
|
|
49
|
+
text = self.screen.get_selected_text() or ""
|
|
50
|
+
|
|
51
|
+
if not text:
|
|
52
|
+
focused = self.screen.focused
|
|
53
|
+
if focused is not None:
|
|
54
|
+
if hasattr(focused, "selected_text") and focused.selected_text:
|
|
55
|
+
text = focused.selected_text
|
|
56
|
+
elif hasattr(focused, "value") and focused.value:
|
|
57
|
+
text = focused.value
|
|
58
|
+
elif hasattr(focused, "text") and focused.text:
|
|
59
|
+
text = focused.text
|
|
60
|
+
|
|
61
|
+
# Fallback: copy full conversation if nothing was specifically selected
|
|
62
|
+
if not text and hasattr(self.screen, "get_conversation_as_text"):
|
|
63
|
+
text = self.screen.get_conversation_as_text()
|
|
64
|
+
|
|
65
|
+
if text:
|
|
66
|
+
import subprocess
|
|
67
|
+
import shutil
|
|
68
|
+
|
|
69
|
+
# 1. Native Wayland clipboard (wl-copy) for Ubuntu Wayland sessions
|
|
70
|
+
wl_copy = shutil.which("wl-copy") or str(Path.home() / ".local" / "bin" / "wl-copy")
|
|
71
|
+
try:
|
|
72
|
+
env = os.environ.copy()
|
|
73
|
+
if "WAYLAND_DISPLAY" not in env:
|
|
74
|
+
env["WAYLAND_DISPLAY"] = "wayland-0"
|
|
75
|
+
p = subprocess.Popen([wl_copy], stdin=subprocess.PIPE, env=env)
|
|
76
|
+
p.communicate(input=text.encode("utf-8"), timeout=1.0)
|
|
77
|
+
except Exception:
|
|
78
|
+
pass
|
|
79
|
+
|
|
80
|
+
# 2. Native X11 clipboard via xclip (for Xwayland / X11 apps)
|
|
81
|
+
xclip = shutil.which("xclip") or str(Path.home() / ".local" / "bin" / "xclip")
|
|
82
|
+
try:
|
|
83
|
+
for sel in ["clipboard", "primary"]:
|
|
84
|
+
p = subprocess.Popen([xclip, "-selection", sel], stdin=subprocess.PIPE)
|
|
85
|
+
p.communicate(input=text.encode("utf-8"), timeout=1.0)
|
|
86
|
+
except Exception:
|
|
87
|
+
pass
|
|
88
|
+
|
|
89
|
+
# 3. Textual native clipboard
|
|
90
|
+
try:
|
|
91
|
+
self.copy_to_clipboard(text)
|
|
92
|
+
except Exception:
|
|
93
|
+
pass
|
|
94
|
+
|
|
95
|
+
# 4. Direct OSC 52 terminal clipboard escape sequence
|
|
96
|
+
try:
|
|
97
|
+
b64 = base64.b64encode(text.encode("utf-8")).decode("ascii")
|
|
98
|
+
sys.stdout.write(f"\033]52;c;{b64}\007")
|
|
99
|
+
sys.stdout.flush()
|
|
100
|
+
except Exception:
|
|
101
|
+
pass
|
|
102
|
+
|
|
103
|
+
# 5. File export for zero-friction copy/paste
|
|
104
|
+
try:
|
|
105
|
+
export_path = Path.home() / ".lang2sim" / "clipboard_export.txt"
|
|
106
|
+
export_path.parent.mkdir(parents=True, exist_ok=True)
|
|
107
|
+
export_path.write_text(text, encoding="utf-8")
|
|
108
|
+
except Exception:
|
|
109
|
+
pass
|
|
110
|
+
|
|
111
|
+
self.notify(f"Copied {len(text)} characters to clipboard!", title="Clipboard", timeout=2.0)
|
|
112
|
+
|
|
113
|
+
def action_copy_or_quit(self) -> None:
|
|
114
|
+
"""Copies selected text if any exists, otherwise exits."""
|
|
115
|
+
text = ""
|
|
116
|
+
if hasattr(self.screen, "_selected_text") and self.screen._selected_text:
|
|
117
|
+
text = self.screen._selected_text
|
|
118
|
+
elif hasattr(self.screen, "get_selected_text"):
|
|
119
|
+
text = self.screen.get_selected_text() or ""
|
|
120
|
+
if not text:
|
|
121
|
+
focused = self.screen.focused
|
|
122
|
+
if focused is not None and hasattr(focused, "selected_text") and focused.selected_text:
|
|
123
|
+
text = focused.selected_text
|
|
124
|
+
|
|
125
|
+
if text:
|
|
126
|
+
self.action_copy_selection()
|
|
127
|
+
else:
|
|
128
|
+
self.exit()
|
|
129
|
+
|
|
130
|
+
def on_mount(self) -> None:
|
|
131
|
+
"""Mount initial landing screen."""
|
|
132
|
+
self.push_screen(LandingScreen())
|
|
133
|
+
|
|
134
|
+
def action_command_palette(self) -> None:
|
|
135
|
+
"""Open custom floating command palette modal."""
|
|
136
|
+
from .screens.command_palette_modal import CommandPaletteModal
|
|
137
|
+
self.push_screen(CommandPaletteModal(), callback=self.on_command_palette_result)
|
|
138
|
+
|
|
139
|
+
def action_open_sessions(self) -> None:
|
|
140
|
+
"""Open custom floating sessions modal."""
|
|
141
|
+
from .screens.sessions_modal import SessionsModal
|
|
142
|
+
self.push_screen(SessionsModal(), callback=self.on_session_selected)
|
|
143
|
+
|
|
144
|
+
def action_new_session(self) -> None:
|
|
145
|
+
"""Create new blank session and navigate to Workspace."""
|
|
146
|
+
from .screens.workspace_screen import WorkspaceScreen
|
|
147
|
+
self.switch_screen(WorkspaceScreen(session_id=None))
|
|
148
|
+
|
|
149
|
+
def on_session_selected(self, session_id: str | None) -> None:
|
|
150
|
+
if not session_id:
|
|
151
|
+
return
|
|
152
|
+
from .screens.workspace_screen import WorkspaceScreen
|
|
153
|
+
if isinstance(self.screen, WorkspaceScreen):
|
|
154
|
+
self.screen.load_session(session_id)
|
|
155
|
+
else:
|
|
156
|
+
self.switch_screen(WorkspaceScreen(session_id=session_id))
|
|
157
|
+
|
|
158
|
+
def on_command_palette_result(self, result: str | None) -> None:
|
|
159
|
+
if not result:
|
|
160
|
+
return
|
|
161
|
+
if result in ("switch_session", "switch_session_all"):
|
|
162
|
+
self.call_after_refresh(self.action_open_sessions)
|
|
163
|
+
elif result == "new_session":
|
|
164
|
+
self.call_after_refresh(self.action_new_session)
|
|
165
|
+
elif result in ("switch_model", "switch_model_agent"):
|
|
166
|
+
self.notify("Model: NVIDIA Nemotron 3 Ultra 550B active", title="Model")
|
|
167
|
+
elif result in ("run_doctor", "sim_doctor"):
|
|
168
|
+
self.notify("All systems nominal: GPU, Display, and Docker ready", title="Doctor")
|
|
169
|
+
elif result == "toggle_headless":
|
|
170
|
+
self.notify("Headless mode toggled", title="Runtime")
|
|
171
|
+
elif result == "connect_stream":
|
|
172
|
+
self.notify("Foxglove stream available at ws://127.0.0.1:8765", title="Stream")
|
|
173
|
+
elif result == "reset_sim":
|
|
174
|
+
self.notify("Emergency world reset dispatched", title="Simulation")
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def main():
|
|
178
|
+
"""Console script entrypoint: lang2sim."""
|
|
179
|
+
app = Lang2SimApp()
|
|
180
|
+
app.run()
|
|
181
|
+
|
|
182
|
+
if __name__ == "__main__":
|
|
183
|
+
main()
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
from .landing_screen import LandingScreen
|
|
2
|
+
from .workspace_screen import WorkspaceScreen
|
|
3
|
+
from .command_palette_modal import CommandPaletteModal
|
|
4
|
+
from .sessions_modal import SessionsModal
|
|
5
|
+
|
|
6
|
+
__all__ = ["LandingScreen", "WorkspaceScreen", "CommandPaletteModal", "SessionsModal"]
|
|
7
|
+
|
|
8
|
+
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Custom Command Palette Modal (ctrl+p)
|
|
3
|
+
File: lang2sim/cli/screens/command_palette_modal.py
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from typing import List, Dict, Optional
|
|
7
|
+
from textual.screen import ModalScreen
|
|
8
|
+
from textual.widgets import Input, Static
|
|
9
|
+
from textual.containers import Vertical, Horizontal, VerticalScroll
|
|
10
|
+
from textual.binding import Binding
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
COMMANDS: List[Dict[str, str]] = [
|
|
14
|
+
# Suggested
|
|
15
|
+
{"id": "switch_session", "category": "Suggested", "title": "Switch session", "shortcut": "ctrl+x l"},
|
|
16
|
+
{"id": "switch_model", "category": "Suggested", "title": "Switch model", "shortcut": "ctrl+x m"},
|
|
17
|
+
{"id": "run_doctor", "category": "Suggested", "title": "Run simulation doctor", "shortcut": "ctrl+x d"},
|
|
18
|
+
|
|
19
|
+
# Session
|
|
20
|
+
{"id": "switch_session_all", "category": "Session", "title": "Switch session", "shortcut": "ctrl+x l"},
|
|
21
|
+
{"id": "new_session", "category": "Session", "title": "New session", "shortcut": "ctrl+x n"},
|
|
22
|
+
{"id": "open_editor", "category": "Session", "title": "Open editor", "shortcut": "ctrl+x e"},
|
|
23
|
+
{"id": "move_session", "category": "Session", "title": "Move session", "description": "Move to another project dir", "shortcut": ""},
|
|
24
|
+
|
|
25
|
+
# Agent
|
|
26
|
+
{"id": "switch_model_agent", "category": "Agent", "title": "Switch model", "shortcut": "ctrl+x m"},
|
|
27
|
+
{"id": "switch_agent", "category": "Agent", "title": "Switch agent", "shortcut": "ctrl+x a"},
|
|
28
|
+
{"id": "toggle_mcps", "category": "Agent", "title": "Toggle MCPs", "shortcut": ""},
|
|
29
|
+
{"id": "variant_cycle", "category": "Agent", "title": "Variant cycle", "shortcut": "ctrl+t"},
|
|
30
|
+
{"id": "switch_variant", "category": "Agent", "title": "Switch model variant", "shortcut": ""},
|
|
31
|
+
|
|
32
|
+
# Runtime & Display
|
|
33
|
+
{"id": "sim_doctor", "category": "Runtime", "title": "Run doctor diagnostic", "shortcut": "ctrl+x d"},
|
|
34
|
+
{"id": "toggle_headless", "category": "Runtime", "title": "Toggle headless mode", "shortcut": "ctrl+x h"},
|
|
35
|
+
{"id": "connect_stream", "category": "Runtime", "title": "Connect Foxglove / noVNC", "shortcut": "ctrl+x f"},
|
|
36
|
+
{"id": "reset_sim", "category": "Runtime", "title": "Emergency reset world", "shortcut": "ctrl+x r"},
|
|
37
|
+
|
|
38
|
+
# Provider
|
|
39
|
+
{"id": "connect_provider", "category": "Provider", "title": "Connect provider", "shortcut": ""},
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class CommandPaletteModal(ModalScreen[Optional[str]]):
|
|
44
|
+
"""Floating Command Palette Modal matching OpenCode UI."""
|
|
45
|
+
|
|
46
|
+
BINDINGS = [
|
|
47
|
+
Binding("escape", "dismiss_modal", "Close", show=False),
|
|
48
|
+
Binding("up", "cursor_up", "Up", show=False),
|
|
49
|
+
Binding("down", "cursor_down", "Down", show=False),
|
|
50
|
+
Binding("enter", "submit_selected", "Select", show=False),
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
def __init__(self, **kwargs):
|
|
54
|
+
super().__init__(**kwargs)
|
|
55
|
+
self.selected_index: int = 0
|
|
56
|
+
self.filtered_commands: List[Dict[str, str]] = list(COMMANDS)
|
|
57
|
+
|
|
58
|
+
def compose(self):
|
|
59
|
+
with Vertical(id="command-palette-dialog"):
|
|
60
|
+
with Horizontal(id="palette-header"):
|
|
61
|
+
yield Static("Commands", id="palette-title")
|
|
62
|
+
yield Static("esc", id="palette-esc")
|
|
63
|
+
|
|
64
|
+
yield Input(placeholder="Search", id="palette-search")
|
|
65
|
+
yield VerticalScroll(id="palette-list")
|
|
66
|
+
|
|
67
|
+
async def on_mount(self) -> None:
|
|
68
|
+
self.query_one(Input).focus()
|
|
69
|
+
await self.rebuild_list()
|
|
70
|
+
|
|
71
|
+
async def on_input_changed(self, event: Input.Changed) -> None:
|
|
72
|
+
query = event.value.strip().lower()
|
|
73
|
+
if not query:
|
|
74
|
+
self.filtered_commands = list(COMMANDS)
|
|
75
|
+
else:
|
|
76
|
+
self.filtered_commands = [
|
|
77
|
+
cmd for cmd in COMMANDS
|
|
78
|
+
if query in cmd["title"].lower()
|
|
79
|
+
or query in cmd["category"].lower()
|
|
80
|
+
or query in cmd.get("description", "").lower()
|
|
81
|
+
]
|
|
82
|
+
self.selected_index = 0
|
|
83
|
+
await self.rebuild_list()
|
|
84
|
+
|
|
85
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
86
|
+
self.action_submit_selected()
|
|
87
|
+
|
|
88
|
+
async def rebuild_list(self) -> None:
|
|
89
|
+
list_container = self.query_one("#palette-list", VerticalScroll)
|
|
90
|
+
await list_container.remove_children()
|
|
91
|
+
|
|
92
|
+
if not self.filtered_commands:
|
|
93
|
+
await list_container.mount(Static("[#6b7280]No matching commands[/]", classes="palette-empty"))
|
|
94
|
+
return
|
|
95
|
+
|
|
96
|
+
current_category = None
|
|
97
|
+
current_item_idx = 0
|
|
98
|
+
|
|
99
|
+
for cmd in self.filtered_commands:
|
|
100
|
+
cat = cmd["category"]
|
|
101
|
+
if cat != current_category:
|
|
102
|
+
current_category = cat
|
|
103
|
+
await list_container.mount(Static(cat, classes="palette-category"))
|
|
104
|
+
|
|
105
|
+
is_selected = (current_item_idx == self.selected_index)
|
|
106
|
+
item_classes = "palette-item -selected" if is_selected else "palette-item"
|
|
107
|
+
|
|
108
|
+
title_text = cmd["title"]
|
|
109
|
+
if cmd.get("description"):
|
|
110
|
+
title_text += f" [#6b7280]{cmd['description']}[/]"
|
|
111
|
+
|
|
112
|
+
children = [Static(title_text, classes="palette-item-title")]
|
|
113
|
+
if cmd.get("shortcut"):
|
|
114
|
+
children.append(Static(cmd["shortcut"], classes="palette-shortcut"))
|
|
115
|
+
|
|
116
|
+
row = Horizontal(*children, classes=item_classes)
|
|
117
|
+
await list_container.mount(row)
|
|
118
|
+
|
|
119
|
+
current_item_idx += 1
|
|
120
|
+
|
|
121
|
+
def update_selection(self) -> None:
|
|
122
|
+
rows = list(self.query("#palette-list .palette-item"))
|
|
123
|
+
for idx, row in enumerate(rows):
|
|
124
|
+
row.set_class(idx == self.selected_index, "-selected")
|
|
125
|
+
if 0 <= self.selected_index < len(rows):
|
|
126
|
+
rows[self.selected_index].scroll_visible(animate=False)
|
|
127
|
+
|
|
128
|
+
def action_cursor_up(self) -> None:
|
|
129
|
+
if self.filtered_commands and self.selected_index > 0:
|
|
130
|
+
self.selected_index -= 1
|
|
131
|
+
self.update_selection()
|
|
132
|
+
|
|
133
|
+
def action_cursor_down(self) -> None:
|
|
134
|
+
if self.filtered_commands and self.selected_index < len(self.filtered_commands) - 1:
|
|
135
|
+
self.selected_index += 1
|
|
136
|
+
self.update_selection()
|
|
137
|
+
|
|
138
|
+
def action_submit_selected(self) -> None:
|
|
139
|
+
if self.filtered_commands and 0 <= self.selected_index < len(self.filtered_commands):
|
|
140
|
+
cmd = self.filtered_commands[self.selected_index]
|
|
141
|
+
self.dismiss(cmd["id"])
|
|
142
|
+
else:
|
|
143
|
+
self.dismiss(None)
|
|
144
|
+
|
|
145
|
+
def action_dismiss_modal(self) -> None:
|
|
146
|
+
self.dismiss(None)
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Screen 1: Landing Screen
|
|
3
|
+
File: lang2sim/cli/screens/landing_screen.py
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from textual.screen import Screen
|
|
7
|
+
from textual.widgets import Static
|
|
8
|
+
from textual.containers import Vertical, Center, Horizontal
|
|
9
|
+
from ..widgets.prompt_card import PromptCard
|
|
10
|
+
|
|
11
|
+
# ── Logo: pixel-bitmap glyphs rendered with half-blocks (2 pixel rows per text row) ──
|
|
12
|
+
GLYPHS = {
|
|
13
|
+
"l": ["##", "##", "##", "##", "##", "##", "..", ".."],
|
|
14
|
+
"a": ["......", "......", "######", "....##", "######", "######", "......", "......"],
|
|
15
|
+
"n": ["......", "......", "######", "##..##", "##..##", "##..##", "......", "......"],
|
|
16
|
+
"g": ["......", "......", "######", "##..##", "######", "....##", "....##", "######"],
|
|
17
|
+
"2": ["######", "....##", "######", "##....", "##....", "######", "......", "......"],
|
|
18
|
+
"s": ["......", "......", "######", "##....", "....##", "######", "......", "......"],
|
|
19
|
+
"i": ["##", "..", "##", "##", "##", "##", "..", ".."],
|
|
20
|
+
"m": ["..........", "..........", "##########", "##..##..##",
|
|
21
|
+
"##..##..##", "##..##..##", "..........", ".........."],
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
# Left-to-right fade: dark gray -> white (one shade per letter)
|
|
25
|
+
COLORS = {
|
|
26
|
+
"l": "#6b7280",
|
|
27
|
+
"a": "#80858f",
|
|
28
|
+
"n": "#979ca6",
|
|
29
|
+
"g": "#adb2ba",
|
|
30
|
+
"2": "#c8ccd2",
|
|
31
|
+
"s": "#dfe1e5",
|
|
32
|
+
"i": "#f1f2f4",
|
|
33
|
+
"m": "#ffffff",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
# (top pixel, bottom pixel) -> half-block character
|
|
37
|
+
HALF = {(0, 0): " ", (1, 0): "▀", (0, 1): "▄", (1, 1): "█"}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def build_logo(word: str = "lang2sim", gap: int = 1) -> str:
|
|
41
|
+
"""Render glyph bitmaps as 4 text rows with per-letter Rich color tags."""
|
|
42
|
+
rows = []
|
|
43
|
+
for r in range(0, 8, 2):
|
|
44
|
+
line = ""
|
|
45
|
+
for idx, ch in enumerate(word):
|
|
46
|
+
g = GLYPHS[ch]
|
|
47
|
+
seg = "".join(
|
|
48
|
+
HALF[(int(g[r][x] == "#"), int(g[r + 1][x] == "#"))]
|
|
49
|
+
for x in range(len(g[0]))
|
|
50
|
+
)
|
|
51
|
+
line += f"[{COLORS[ch]}]{seg}[/]"
|
|
52
|
+
if idx < len(word) - 1:
|
|
53
|
+
line += " " * gap
|
|
54
|
+
rows.append(line)
|
|
55
|
+
return "\n".join(rows)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
LOGO_TEXT = build_logo()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class LandingScreen(Screen):
|
|
62
|
+
"""The initial landing screen matching the reference image."""
|
|
63
|
+
|
|
64
|
+
def compose(self):
|
|
65
|
+
with Center(id="landing-container"):
|
|
66
|
+
with Vertical(id="landing-inner"):
|
|
67
|
+
yield Static(LOGO_TEXT, id="landing-logo")
|
|
68
|
+
yield PromptCard(
|
|
69
|
+
placeholder="Ask anything.. 'Spawn 14x10m warehouse with 8 racks, send AMR to rack 5'",
|
|
70
|
+
card_id="landing-prompt-input",
|
|
71
|
+
id="landing-card",
|
|
72
|
+
)
|
|
73
|
+
with Horizontal(id="shortcuts-container"):
|
|
74
|
+
yield Static(
|
|
75
|
+
"[#6b7280]tab[/] [#9ca3af]agents[/] [#6b7280]ctrl+p[/] [#9ca3af]commands[/]",
|
|
76
|
+
id="landing-shortcuts",
|
|
77
|
+
)
|
|
78
|
+
with Center():
|
|
79
|
+
yield Static(
|
|
80
|
+
"[#f59e0b]●[/] [bold #f3f4f6]Tip[/] [#9ca3af]Press[/] "
|
|
81
|
+
"[bold #ffffff]shift+enter[/] [#9ca3af]to add newlines in your prompt[/]",
|
|
82
|
+
id="landing-tip",
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
with Horizontal(id="workspace-footer"):
|
|
86
|
+
yield Static("~", classes="footer-left")
|
|
87
|
+
yield Static("", classes="footer-spacer")
|
|
88
|
+
yield Static("[#6b7280]lang2sim 2.0.0[/]", classes="footer-right")
|
|
89
|
+
|
|
90
|
+
def on_mount(self) -> None:
|
|
91
|
+
self.query_one(PromptCard).focus_input()
|
|
92
|
+
|
|
93
|
+
def on_prompt_card_submitted(self, message: PromptCard.Submitted) -> None:
|
|
94
|
+
"""When user submits a prompt, transition to WorkspaceScreen."""
|
|
95
|
+
from .workspace_screen import WorkspaceScreen
|
|
96
|
+
self.app.switch_screen(WorkspaceScreen(initial_prompt=message.value))
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Sessions Modal (ctrl+s / Command Palette)
|
|
3
|
+
File: lang2sim/cli/screens/sessions_modal.py
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from typing import List, Dict, Optional
|
|
7
|
+
from textual.screen import ModalScreen
|
|
8
|
+
from textual.widgets import Static, Input
|
|
9
|
+
from textual.containers import Vertical, Horizontal, VerticalScroll
|
|
10
|
+
from textual.binding import Binding
|
|
11
|
+
from ...core.session_manager import session_manager, ChatSession
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class RenameModal(ModalScreen[Optional[str]]):
|
|
15
|
+
"""Small popup modal to rename a session."""
|
|
16
|
+
|
|
17
|
+
DEFAULT_CSS = """
|
|
18
|
+
RenameModal {
|
|
19
|
+
align: center middle;
|
|
20
|
+
background: rgba(0, 0, 0, 0.75);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
#rename-dialog {
|
|
24
|
+
width: 50;
|
|
25
|
+
height: auto;
|
|
26
|
+
background: #161618;
|
|
27
|
+
border: solid #27272a;
|
|
28
|
+
padding: 1 2;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
#rename-title {
|
|
32
|
+
color: #ffffff;
|
|
33
|
+
text-style: bold;
|
|
34
|
+
margin-bottom: 1;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
#rename-input {
|
|
38
|
+
background: #09090b;
|
|
39
|
+
border: solid #3f3f46;
|
|
40
|
+
color: #ffffff;
|
|
41
|
+
}
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
BINDINGS = [
|
|
45
|
+
Binding("escape", "cancel", "Cancel"),
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
def __init__(self, current_title: str, **kwargs):
|
|
49
|
+
super().__init__(**kwargs)
|
|
50
|
+
self.current_title = current_title
|
|
51
|
+
|
|
52
|
+
def compose(self):
|
|
53
|
+
with Vertical(id="rename-dialog"):
|
|
54
|
+
yield Static("Rename Session", id="rename-title")
|
|
55
|
+
yield Input(value=self.current_title, id="rename-input")
|
|
56
|
+
|
|
57
|
+
def on_mount(self) -> None:
|
|
58
|
+
inp = self.query_one("#rename-input", Input)
|
|
59
|
+
inp.focus()
|
|
60
|
+
|
|
61
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
62
|
+
val = event.value.strip()
|
|
63
|
+
self.dismiss(val if val else None)
|
|
64
|
+
|
|
65
|
+
def action_cancel(self) -> None:
|
|
66
|
+
self.dismiss(None)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class SessionsModal(ModalScreen[Optional[str]]):
|
|
70
|
+
"""Modal dialog displaying grouped chat sessions with search, pin, delete, and rename."""
|
|
71
|
+
|
|
72
|
+
BINDINGS = [
|
|
73
|
+
Binding("escape", "dismiss_modal", "Dismiss", show=False),
|
|
74
|
+
Binding("up", "cursor_up", "Cursor Up", show=False),
|
|
75
|
+
Binding("down", "cursor_down", "Cursor Down", show=False),
|
|
76
|
+
Binding("enter", "submit_selected", "Open Session", show=False),
|
|
77
|
+
Binding("ctrl+f", "toggle_pin", "Pin/Unpin", show=False),
|
|
78
|
+
Binding("ctrl+d", "delete_selected", "Delete", show=False),
|
|
79
|
+
Binding("ctrl+r", "rename_selected", "Rename", show=False),
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
def __init__(self, workspace_name: Optional[str] = None, **kwargs):
|
|
83
|
+
super().__init__(**kwargs)
|
|
84
|
+
self.workspace_name = workspace_name
|
|
85
|
+
self.all_sessions: List[ChatSession] = []
|
|
86
|
+
self.filtered_sessions: List[ChatSession] = []
|
|
87
|
+
self.selected_index: int = 0
|
|
88
|
+
|
|
89
|
+
def compose(self):
|
|
90
|
+
with Vertical(id="sessions-dialog"):
|
|
91
|
+
# Header: Sessions | esc
|
|
92
|
+
with Horizontal(id="sessions-header"):
|
|
93
|
+
yield Static("Sessions", id="sessions-title")
|
|
94
|
+
yield Static("", classes="header-spacer")
|
|
95
|
+
yield Static("esc", id="sessions-esc")
|
|
96
|
+
|
|
97
|
+
# Search bar
|
|
98
|
+
yield Input(placeholder="Search", id="sessions-search")
|
|
99
|
+
|
|
100
|
+
# Sessions list container
|
|
101
|
+
with VerticalScroll(id="sessions-list"):
|
|
102
|
+
yield Static("", id="sessions-list-anchor")
|
|
103
|
+
|
|
104
|
+
# Footer actions bar
|
|
105
|
+
with Horizontal(id="sessions-footer"):
|
|
106
|
+
yield Static(
|
|
107
|
+
"pin/unpin [bold #9ca3af]ctrl+f[/] delete [bold #9ca3af]ctrl+d[/] rename [bold #9ca3af]ctrl+r[/]",
|
|
108
|
+
id="sessions-footer-actions",
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
async def on_mount(self) -> None:
|
|
112
|
+
self.load_and_refresh()
|
|
113
|
+
self.query_one("#sessions-search", Input).focus()
|
|
114
|
+
|
|
115
|
+
def load_and_refresh(self) -> None:
|
|
116
|
+
self.all_sessions = session_manager.list_sessions(self.workspace_name)
|
|
117
|
+
search_query = ""
|
|
118
|
+
try:
|
|
119
|
+
search_query = self.query_one("#sessions-search", Input).value.strip().lower()
|
|
120
|
+
except Exception:
|
|
121
|
+
pass
|
|
122
|
+
self.filter_sessions(search_query)
|
|
123
|
+
|
|
124
|
+
def on_input_changed(self, event: Input.Changed) -> None:
|
|
125
|
+
if event.input.id == "sessions-search":
|
|
126
|
+
self.filter_sessions(event.value.strip().lower())
|
|
127
|
+
|
|
128
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
129
|
+
if event.input.id == "sessions-search":
|
|
130
|
+
self.action_submit_selected()
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def filter_sessions(self, query: str) -> None:
|
|
134
|
+
if not query:
|
|
135
|
+
self.filtered_sessions = list(self.all_sessions)
|
|
136
|
+
else:
|
|
137
|
+
self.filtered_sessions = [
|
|
138
|
+
s for s in self.all_sessions if query in s.title.lower()
|
|
139
|
+
]
|
|
140
|
+
|
|
141
|
+
if self.selected_index >= len(self.filtered_sessions):
|
|
142
|
+
self.selected_index = max(0, len(self.filtered_sessions) - 1)
|
|
143
|
+
|
|
144
|
+
self.run_worker(self.rebuild_list())
|
|
145
|
+
|
|
146
|
+
async def rebuild_list(self) -> None:
|
|
147
|
+
list_container = self.query_one("#sessions-list", VerticalScroll)
|
|
148
|
+
await list_container.remove_children()
|
|
149
|
+
|
|
150
|
+
if not self.filtered_sessions:
|
|
151
|
+
row = Horizontal(
|
|
152
|
+
Static("[#71717a]No matching sessions found[/]", classes="session-empty")
|
|
153
|
+
)
|
|
154
|
+
await list_container.mount(row)
|
|
155
|
+
return
|
|
156
|
+
|
|
157
|
+
grouped = session_manager.group_sessions_by_date(self.filtered_sessions)
|
|
158
|
+
current_item_idx = 0
|
|
159
|
+
|
|
160
|
+
for header, sessions in grouped.items():
|
|
161
|
+
# Date group header (lavender)
|
|
162
|
+
header_row = Horizontal(
|
|
163
|
+
Static(f"[bold #a78bfa]{header}[/]", classes="session-group-header")
|
|
164
|
+
)
|
|
165
|
+
await list_container.mount(header_row)
|
|
166
|
+
|
|
167
|
+
for s in sessions:
|
|
168
|
+
is_selected = (current_item_idx == self.selected_index)
|
|
169
|
+
item_classes = "session-item -selected" if is_selected else "session-item"
|
|
170
|
+
bullet = "● " if is_selected else " "
|
|
171
|
+
title_text = s.title
|
|
172
|
+
|
|
173
|
+
row = Horizontal(
|
|
174
|
+
Static(f"{bullet}{title_text}", classes="session-item-text"),
|
|
175
|
+
classes=item_classes,
|
|
176
|
+
)
|
|
177
|
+
await list_container.mount(row)
|
|
178
|
+
current_item_idx += 1
|
|
179
|
+
|
|
180
|
+
def update_selection(self) -> None:
|
|
181
|
+
rows = list(self.query("#sessions-list .session-item"))
|
|
182
|
+
for idx, row in enumerate(rows):
|
|
183
|
+
is_selected = (idx == self.selected_index)
|
|
184
|
+
row.set_class(is_selected, "-selected")
|
|
185
|
+
try:
|
|
186
|
+
txt_widget = row.query_one(".session-item-text", Static)
|
|
187
|
+
raw_text = self.filtered_sessions[idx].title
|
|
188
|
+
bullet = "● " if is_selected else " "
|
|
189
|
+
txt_widget.update(f"{bullet}{raw_text}")
|
|
190
|
+
except Exception:
|
|
191
|
+
pass
|
|
192
|
+
if 0 <= self.selected_index < len(rows):
|
|
193
|
+
rows[self.selected_index].scroll_visible(animate=False)
|
|
194
|
+
|
|
195
|
+
def action_cursor_up(self) -> None:
|
|
196
|
+
if self.filtered_sessions and self.selected_index > 0:
|
|
197
|
+
self.selected_index -= 1
|
|
198
|
+
self.update_selection()
|
|
199
|
+
|
|
200
|
+
def action_cursor_down(self) -> None:
|
|
201
|
+
if self.filtered_sessions and self.selected_index < len(self.filtered_sessions) - 1:
|
|
202
|
+
self.selected_index += 1
|
|
203
|
+
self.update_selection()
|
|
204
|
+
|
|
205
|
+
def action_submit_selected(self) -> None:
|
|
206
|
+
if self.filtered_sessions and 0 <= self.selected_index < len(self.filtered_sessions):
|
|
207
|
+
chosen = self.filtered_sessions[self.selected_index]
|
|
208
|
+
self.dismiss(chosen.id)
|
|
209
|
+
else:
|
|
210
|
+
self.dismiss(None)
|
|
211
|
+
|
|
212
|
+
def action_toggle_pin(self) -> None:
|
|
213
|
+
if self.filtered_sessions and 0 <= self.selected_index < len(self.filtered_sessions):
|
|
214
|
+
target = self.filtered_sessions[self.selected_index]
|
|
215
|
+
session_manager.pin_session(target.id)
|
|
216
|
+
self.load_and_refresh()
|
|
217
|
+
|
|
218
|
+
def action_delete_selected(self) -> None:
|
|
219
|
+
if self.filtered_sessions and 0 <= self.selected_index < len(self.filtered_sessions):
|
|
220
|
+
target = self.filtered_sessions[self.selected_index]
|
|
221
|
+
session_manager.delete_session(target.id)
|
|
222
|
+
self.load_and_refresh()
|
|
223
|
+
|
|
224
|
+
def action_rename_selected(self) -> None:
|
|
225
|
+
if self.filtered_sessions and 0 <= self.selected_index < len(self.filtered_sessions):
|
|
226
|
+
target = self.filtered_sessions[self.selected_index]
|
|
227
|
+
|
|
228
|
+
def on_renamed(new_name: Optional[str]) -> None:
|
|
229
|
+
if new_name:
|
|
230
|
+
session_manager.rename_session(target.id, new_name)
|
|
231
|
+
self.load_and_refresh()
|
|
232
|
+
|
|
233
|
+
self.app.push_screen(RenameModal(current_title=target.title), callback=on_renamed)
|
|
234
|
+
|
|
235
|
+
def action_dismiss_modal(self) -> None:
|
|
236
|
+
self.dismiss(None)
|