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
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)