kollabor 0.4.9__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.
- core/__init__.py +18 -0
- core/application.py +578 -0
- core/cli.py +193 -0
- core/commands/__init__.py +43 -0
- core/commands/executor.py +277 -0
- core/commands/menu_renderer.py +319 -0
- core/commands/parser.py +186 -0
- core/commands/registry.py +331 -0
- core/commands/system_commands.py +479 -0
- core/config/__init__.py +7 -0
- core/config/llm_task_config.py +110 -0
- core/config/loader.py +501 -0
- core/config/manager.py +112 -0
- core/config/plugin_config_manager.py +346 -0
- core/config/plugin_schema.py +424 -0
- core/config/service.py +399 -0
- core/effects/__init__.py +1 -0
- core/events/__init__.py +12 -0
- core/events/bus.py +129 -0
- core/events/executor.py +154 -0
- core/events/models.py +258 -0
- core/events/processor.py +176 -0
- core/events/registry.py +289 -0
- core/fullscreen/__init__.py +19 -0
- core/fullscreen/command_integration.py +290 -0
- core/fullscreen/components/__init__.py +12 -0
- core/fullscreen/components/animation.py +258 -0
- core/fullscreen/components/drawing.py +160 -0
- core/fullscreen/components/matrix_components.py +177 -0
- core/fullscreen/manager.py +302 -0
- core/fullscreen/plugin.py +204 -0
- core/fullscreen/renderer.py +282 -0
- core/fullscreen/session.py +324 -0
- core/io/__init__.py +52 -0
- core/io/buffer_manager.py +362 -0
- core/io/config_status_view.py +272 -0
- core/io/core_status_views.py +410 -0
- core/io/input_errors.py +313 -0
- core/io/input_handler.py +2655 -0
- core/io/input_mode_manager.py +402 -0
- core/io/key_parser.py +344 -0
- core/io/layout.py +587 -0
- core/io/message_coordinator.py +204 -0
- core/io/message_renderer.py +601 -0
- core/io/modal_interaction_handler.py +315 -0
- core/io/raw_input_processor.py +946 -0
- core/io/status_renderer.py +845 -0
- core/io/terminal_renderer.py +586 -0
- core/io/terminal_state.py +551 -0
- core/io/visual_effects.py +734 -0
- core/llm/__init__.py +26 -0
- core/llm/api_communication_service.py +863 -0
- core/llm/conversation_logger.py +473 -0
- core/llm/conversation_manager.py +414 -0
- core/llm/file_operations_executor.py +1401 -0
- core/llm/hook_system.py +402 -0
- core/llm/llm_service.py +1629 -0
- core/llm/mcp_integration.py +386 -0
- core/llm/message_display_service.py +450 -0
- core/llm/model_router.py +214 -0
- core/llm/plugin_sdk.py +396 -0
- core/llm/response_parser.py +848 -0
- core/llm/response_processor.py +364 -0
- core/llm/tool_executor.py +520 -0
- core/logging/__init__.py +19 -0
- core/logging/setup.py +208 -0
- core/models/__init__.py +5 -0
- core/models/base.py +23 -0
- core/plugins/__init__.py +13 -0
- core/plugins/collector.py +212 -0
- core/plugins/discovery.py +386 -0
- core/plugins/factory.py +263 -0
- core/plugins/registry.py +152 -0
- core/storage/__init__.py +5 -0
- core/storage/state_manager.py +84 -0
- core/ui/__init__.py +6 -0
- core/ui/config_merger.py +176 -0
- core/ui/config_widgets.py +369 -0
- core/ui/live_modal_renderer.py +276 -0
- core/ui/modal_actions.py +162 -0
- core/ui/modal_overlay_renderer.py +373 -0
- core/ui/modal_renderer.py +591 -0
- core/ui/modal_state_manager.py +443 -0
- core/ui/widget_integration.py +222 -0
- core/ui/widgets/__init__.py +27 -0
- core/ui/widgets/base_widget.py +136 -0
- core/ui/widgets/checkbox.py +85 -0
- core/ui/widgets/dropdown.py +140 -0
- core/ui/widgets/label.py +78 -0
- core/ui/widgets/slider.py +185 -0
- core/ui/widgets/text_input.py +224 -0
- core/utils/__init__.py +11 -0
- core/utils/config_utils.py +656 -0
- core/utils/dict_utils.py +212 -0
- core/utils/error_utils.py +275 -0
- core/utils/key_reader.py +171 -0
- core/utils/plugin_utils.py +267 -0
- core/utils/prompt_renderer.py +151 -0
- kollabor-0.4.9.dist-info/METADATA +298 -0
- kollabor-0.4.9.dist-info/RECORD +128 -0
- kollabor-0.4.9.dist-info/WHEEL +5 -0
- kollabor-0.4.9.dist-info/entry_points.txt +2 -0
- kollabor-0.4.9.dist-info/licenses/LICENSE +21 -0
- kollabor-0.4.9.dist-info/top_level.txt +4 -0
- kollabor_cli_main.py +20 -0
- plugins/__init__.py +1 -0
- plugins/enhanced_input/__init__.py +18 -0
- plugins/enhanced_input/box_renderer.py +103 -0
- plugins/enhanced_input/box_styles.py +142 -0
- plugins/enhanced_input/color_engine.py +165 -0
- plugins/enhanced_input/config.py +150 -0
- plugins/enhanced_input/cursor_manager.py +72 -0
- plugins/enhanced_input/geometry.py +81 -0
- plugins/enhanced_input/state.py +130 -0
- plugins/enhanced_input/text_processor.py +115 -0
- plugins/enhanced_input_plugin.py +385 -0
- plugins/fullscreen/__init__.py +9 -0
- plugins/fullscreen/example_plugin.py +327 -0
- plugins/fullscreen/matrix_plugin.py +132 -0
- plugins/hook_monitoring_plugin.py +1299 -0
- plugins/query_enhancer_plugin.py +350 -0
- plugins/save_conversation_plugin.py +502 -0
- plugins/system_commands_plugin.py +93 -0
- plugins/tmux_plugin.py +795 -0
- plugins/workflow_enforcement_plugin.py +629 -0
- system_prompt/default.md +1286 -0
- system_prompt/default_win.md +265 -0
- system_prompt/example_with_trender.md +47 -0
plugins/tmux_plugin.py
ADDED
|
@@ -0,0 +1,795 @@
|
|
|
1
|
+
"""Tmux integration plugin for managing and viewing tmux sessions.
|
|
2
|
+
|
|
3
|
+
Provides commands to:
|
|
4
|
+
- Create new tmux sessions with commands
|
|
5
|
+
- View live tmux session output in alt buffer
|
|
6
|
+
- List active sessions
|
|
7
|
+
- Kill sessions
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import subprocess
|
|
12
|
+
import logging
|
|
13
|
+
import shutil
|
|
14
|
+
from typing import Dict, Any, List, Optional
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from datetime import datetime
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class TmuxSession:
|
|
23
|
+
"""Represents a managed tmux session."""
|
|
24
|
+
name: str
|
|
25
|
+
command: str
|
|
26
|
+
created_at: datetime = field(default_factory=datetime.now)
|
|
27
|
+
pid: Optional[int] = None
|
|
28
|
+
|
|
29
|
+
def is_alive(self) -> bool:
|
|
30
|
+
"""Check if the tmux session is still running."""
|
|
31
|
+
try:
|
|
32
|
+
result = subprocess.run(
|
|
33
|
+
["tmux", "has-session", "-t", self.name],
|
|
34
|
+
capture_output=True
|
|
35
|
+
)
|
|
36
|
+
return result.returncode == 0
|
|
37
|
+
except Exception:
|
|
38
|
+
return False
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class TmuxPlugin:
|
|
42
|
+
"""Plugin for tmux session management and live viewing."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, name: str = "tmux", state_manager=None, event_bus=None,
|
|
45
|
+
renderer=None, config=None):
|
|
46
|
+
"""Initialize the tmux plugin.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
name: Plugin name.
|
|
50
|
+
state_manager: State management system.
|
|
51
|
+
event_bus: Event bus for hook registration.
|
|
52
|
+
renderer: Terminal renderer.
|
|
53
|
+
config: Configuration manager.
|
|
54
|
+
"""
|
|
55
|
+
self.name = name
|
|
56
|
+
self.version = "1.0.0"
|
|
57
|
+
self.description = "Manage and view tmux sessions"
|
|
58
|
+
self.enabled = True
|
|
59
|
+
|
|
60
|
+
self.sessions: Dict[str, TmuxSession] = {}
|
|
61
|
+
self.state_manager = state_manager
|
|
62
|
+
self.event_bus = event_bus
|
|
63
|
+
self.renderer = renderer
|
|
64
|
+
self.config = config
|
|
65
|
+
self.command_registry = None
|
|
66
|
+
self.input_handler = None
|
|
67
|
+
self._current_session: Optional[str] = None # Currently viewing session
|
|
68
|
+
self._last_arrow_time: float = 0.0 # For double-arrow detection
|
|
69
|
+
self._last_arrow_dir: Optional[str] = None # "Left" or "Right"
|
|
70
|
+
self._double_arrow_threshold: float = 0.3 # seconds
|
|
71
|
+
|
|
72
|
+
self.logger = logger
|
|
73
|
+
|
|
74
|
+
@staticmethod
|
|
75
|
+
def get_default_config() -> Dict[str, Any]:
|
|
76
|
+
"""Get default configuration for tmux plugin."""
|
|
77
|
+
return {
|
|
78
|
+
"plugins": {
|
|
79
|
+
"tmux": {
|
|
80
|
+
"enabled": True,
|
|
81
|
+
"show_status": True,
|
|
82
|
+
"refresh_rate": 0.1, # Live view refresh rate in seconds
|
|
83
|
+
"default_shell": "/bin/bash"
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async def initialize(self, event_bus, config, **kwargs) -> None:
|
|
89
|
+
"""Initialize the plugin.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
event_bus: Application event bus.
|
|
93
|
+
config: Configuration manager.
|
|
94
|
+
**kwargs: Additional parameters including command_registry.
|
|
95
|
+
"""
|
|
96
|
+
try:
|
|
97
|
+
self.event_bus = event_bus
|
|
98
|
+
self.config = config
|
|
99
|
+
self.command_registry = kwargs.get('command_registry')
|
|
100
|
+
self.input_handler = kwargs.get('input_handler')
|
|
101
|
+
self.renderer = kwargs.get('renderer')
|
|
102
|
+
|
|
103
|
+
# Check if tmux is available
|
|
104
|
+
if not self._check_tmux_available():
|
|
105
|
+
self.logger.warning("tmux not found in PATH - tmux plugin disabled")
|
|
106
|
+
self.enabled = False
|
|
107
|
+
return
|
|
108
|
+
|
|
109
|
+
# Register commands
|
|
110
|
+
if self.command_registry:
|
|
111
|
+
self._register_commands()
|
|
112
|
+
|
|
113
|
+
# Discover existing sessions
|
|
114
|
+
self._discover_existing_sessions()
|
|
115
|
+
|
|
116
|
+
# Register status view
|
|
117
|
+
await self._register_status_view()
|
|
118
|
+
|
|
119
|
+
self.logger.info("Tmux plugin initialized successfully")
|
|
120
|
+
|
|
121
|
+
except Exception as e:
|
|
122
|
+
self.logger.error(f"Error initializing tmux plugin: {e}")
|
|
123
|
+
raise
|
|
124
|
+
|
|
125
|
+
def _check_tmux_available(self) -> bool:
|
|
126
|
+
"""Check if tmux is available on the system."""
|
|
127
|
+
return shutil.which("tmux") is not None
|
|
128
|
+
|
|
129
|
+
def _register_commands(self):
|
|
130
|
+
"""Register tmux commands with the command registry."""
|
|
131
|
+
from core.events.models import (
|
|
132
|
+
CommandDefinition, CommandMode, CommandCategory
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
# /terminal - manage tmux sessions
|
|
136
|
+
terminal_cmd = CommandDefinition(
|
|
137
|
+
name="terminal",
|
|
138
|
+
description="Manage tmux sessions (new/view/list/kill)",
|
|
139
|
+
handler=self._handle_tmux_command,
|
|
140
|
+
plugin_name=self.name,
|
|
141
|
+
category=CommandCategory.SYSTEM,
|
|
142
|
+
mode=CommandMode.INSTANT,
|
|
143
|
+
aliases=["term", "tmux", "t"],
|
|
144
|
+
icon="[>_]"
|
|
145
|
+
)
|
|
146
|
+
self.command_registry.register_command(terminal_cmd)
|
|
147
|
+
|
|
148
|
+
self.logger.info("Tmux commands registered")
|
|
149
|
+
|
|
150
|
+
async def _handle_tmux_command(self, command) -> "CommandResult":
|
|
151
|
+
"""Handle /tmux command with subcommands.
|
|
152
|
+
|
|
153
|
+
Usage:
|
|
154
|
+
/tmux new <command> <session_name> - Create new session
|
|
155
|
+
/tmux view <session_name> - Live view session
|
|
156
|
+
/tmux list - List sessions
|
|
157
|
+
/tmux kill <session_name> - Kill session
|
|
158
|
+
/tmux attach <session_name> - Attach to session (exits kollabor)
|
|
159
|
+
"""
|
|
160
|
+
from core.events.models import CommandResult
|
|
161
|
+
|
|
162
|
+
args = command.args if command.args else []
|
|
163
|
+
|
|
164
|
+
if not args:
|
|
165
|
+
# No args - go directly to view mode
|
|
166
|
+
return await self._handle_view_session([])
|
|
167
|
+
|
|
168
|
+
subcommand = args[0].lower()
|
|
169
|
+
|
|
170
|
+
if subcommand == "new":
|
|
171
|
+
return await self._handle_new_session(args[1:])
|
|
172
|
+
elif subcommand == "view":
|
|
173
|
+
return await self._handle_view_session(args[1:])
|
|
174
|
+
elif subcommand == "list" or subcommand == "ls":
|
|
175
|
+
return await self._handle_list_sessions()
|
|
176
|
+
elif subcommand == "kill":
|
|
177
|
+
return await self._handle_kill_session(args[1:])
|
|
178
|
+
elif subcommand == "attach":
|
|
179
|
+
return await self._handle_attach_session(args[1:])
|
|
180
|
+
elif subcommand == "help" or subcommand == "--help" or subcommand == "-h":
|
|
181
|
+
return CommandResult(
|
|
182
|
+
success=True,
|
|
183
|
+
message=self._get_help_text(),
|
|
184
|
+
display_type="info"
|
|
185
|
+
)
|
|
186
|
+
else:
|
|
187
|
+
return CommandResult(
|
|
188
|
+
success=False,
|
|
189
|
+
message=f"Unknown subcommand: {subcommand}\n\n{self._get_help_text()}",
|
|
190
|
+
display_type="error"
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
def _get_help_text(self) -> str:
|
|
194
|
+
"""Get help text for terminal command."""
|
|
195
|
+
return """Terminal Session Manager
|
|
196
|
+
|
|
197
|
+
Usage:
|
|
198
|
+
/terminal new <name> <command> Create new session running command
|
|
199
|
+
/terminal view [name] Live view session (</> to cycle)
|
|
200
|
+
/terminal list List all sessions
|
|
201
|
+
/terminal kill <name> Kill a session
|
|
202
|
+
/terminal attach <name> Attach to session (leaves kollabor)
|
|
203
|
+
|
|
204
|
+
Examples:
|
|
205
|
+
/terminal new myserver python -m http.server 8080
|
|
206
|
+
/terminal new logs tail -f /var/log/syslog
|
|
207
|
+
/terminal view
|
|
208
|
+
/terminal kill myserver
|
|
209
|
+
|
|
210
|
+
Aliases: /t, /term, /tmux"""
|
|
211
|
+
|
|
212
|
+
async def _handle_new_session(self, args: List[str]) -> "CommandResult":
|
|
213
|
+
"""Create a new tmux session."""
|
|
214
|
+
from core.events.models import CommandResult
|
|
215
|
+
|
|
216
|
+
if len(args) < 1:
|
|
217
|
+
return CommandResult(
|
|
218
|
+
success=False,
|
|
219
|
+
message="Usage: /terminal new <session_name> [command]",
|
|
220
|
+
display_type="error"
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
session_name = args[0]
|
|
224
|
+
command = " ".join(args[1:]) if len(args) > 1 else None
|
|
225
|
+
|
|
226
|
+
# Check if session already exists
|
|
227
|
+
if session_name in self.sessions and self.sessions[session_name].is_alive():
|
|
228
|
+
return CommandResult(
|
|
229
|
+
success=False,
|
|
230
|
+
message=f"Session '{session_name}' already exists",
|
|
231
|
+
display_type="error"
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
try:
|
|
235
|
+
# Create detached tmux session with interactive bash shell
|
|
236
|
+
result = subprocess.run(
|
|
237
|
+
["tmux", "new-session", "-d", "-s", session_name, "bash", "-i"],
|
|
238
|
+
capture_output=True,
|
|
239
|
+
text=True
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
if result.returncode != 0:
|
|
243
|
+
return CommandResult(
|
|
244
|
+
success=False,
|
|
245
|
+
message=f"Failed to create session: {result.stderr}",
|
|
246
|
+
display_type="error"
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
# Send the command to the new session (if provided)
|
|
250
|
+
if command:
|
|
251
|
+
send_result = subprocess.run(
|
|
252
|
+
["tmux", "send-keys", "-t", session_name, command, "Enter"],
|
|
253
|
+
capture_output=True,
|
|
254
|
+
text=True
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
if send_result.returncode != 0:
|
|
258
|
+
self.logger.warning(f"Session created but failed to send command: {send_result.stderr}")
|
|
259
|
+
|
|
260
|
+
# Track the session
|
|
261
|
+
self.sessions[session_name] = TmuxSession(
|
|
262
|
+
name=session_name,
|
|
263
|
+
command=command or "bash"
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
msg = f"Created session '{session_name}'"
|
|
267
|
+
if command:
|
|
268
|
+
msg += f" running: {command}"
|
|
269
|
+
return CommandResult(
|
|
270
|
+
success=True,
|
|
271
|
+
message=msg,
|
|
272
|
+
display_type="success"
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
except Exception as e:
|
|
276
|
+
return CommandResult(
|
|
277
|
+
success=False,
|
|
278
|
+
message=f"Error creating session: {e}",
|
|
279
|
+
display_type="error"
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
async def _handle_view_session(self, args: List[str]) -> "CommandResult":
|
|
283
|
+
"""View a tmux session live in alt buffer."""
|
|
284
|
+
from core.events.models import CommandResult
|
|
285
|
+
|
|
286
|
+
# If no args, get first available session
|
|
287
|
+
if not args:
|
|
288
|
+
sessions = self._get_all_tmux_sessions()
|
|
289
|
+
if not sessions:
|
|
290
|
+
return CommandResult(
|
|
291
|
+
success=False,
|
|
292
|
+
message="No tmux sessions found. Use '/tmux new <name> <command>' to create one.",
|
|
293
|
+
display_type="error"
|
|
294
|
+
)
|
|
295
|
+
session_name = sessions[0]
|
|
296
|
+
else:
|
|
297
|
+
session_name = args[0]
|
|
298
|
+
# Check if session exists (in tmux, not just our tracking)
|
|
299
|
+
if not self._session_exists(session_name):
|
|
300
|
+
return CommandResult(
|
|
301
|
+
success=False,
|
|
302
|
+
message=f"Session '{session_name}' not found",
|
|
303
|
+
display_type="error"
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
# Emit live modal trigger event
|
|
307
|
+
if self.event_bus:
|
|
308
|
+
from core.ui.live_modal_renderer import LiveModalConfig
|
|
309
|
+
from core.events.models import EventType
|
|
310
|
+
|
|
311
|
+
# Content generator - uses self._current_session for dynamic switching
|
|
312
|
+
async def get_tmux_content() -> List[str]:
|
|
313
|
+
# Show session name as header line
|
|
314
|
+
sessions = self._get_all_tmux_sessions()
|
|
315
|
+
session_idx = sessions.index(self._current_session) + 1 if self._current_session in sessions else 1
|
|
316
|
+
header = f"[Session: {self._current_session}] ({session_idx}/{len(sessions)})"
|
|
317
|
+
content = self._capture_tmux_pane(self._current_session)
|
|
318
|
+
return [header, "─" * len(header)] + content
|
|
319
|
+
|
|
320
|
+
# Input callback for passthrough
|
|
321
|
+
async def handle_input(key_press) -> bool:
|
|
322
|
+
if key_press.name == "Escape":
|
|
323
|
+
return True # Exit
|
|
324
|
+
|
|
325
|
+
# Key8776 (≈) kills the current session
|
|
326
|
+
if key_press.code == 8776:
|
|
327
|
+
try:
|
|
328
|
+
subprocess.run(
|
|
329
|
+
["tmux", "kill-session", "-t", self._current_session],
|
|
330
|
+
capture_output=True,
|
|
331
|
+
text=True,
|
|
332
|
+
timeout=2
|
|
333
|
+
)
|
|
334
|
+
logger.info(f"Killed tmux session: {self._current_session}")
|
|
335
|
+
return True # Exit modal after killing session
|
|
336
|
+
except Exception as e:
|
|
337
|
+
logger.error(f"Failed to kill tmux session {self._current_session}: {e}")
|
|
338
|
+
return False
|
|
339
|
+
|
|
340
|
+
# Use Opt+Right (Alt+ArrowRight) and Opt+Left (Alt+ArrowLeft) to cycle sessions
|
|
341
|
+
# Support both Alt+Arrow and Alt+letter sequences
|
|
342
|
+
|
|
343
|
+
if key_press.name == "Alt+ArrowRight":
|
|
344
|
+
new_session = self._cycle_session(forward=True)
|
|
345
|
+
if new_session:
|
|
346
|
+
self._current_session = new_session
|
|
347
|
+
return False
|
|
348
|
+
elif key_press.name == "Alt+ArrowLeft":
|
|
349
|
+
new_session = self._cycle_session(forward=False)
|
|
350
|
+
if new_session:
|
|
351
|
+
self._current_session = new_session
|
|
352
|
+
return False
|
|
353
|
+
# Also support Alt+f and Alt+b for terminals that use these sequences
|
|
354
|
+
elif key_press.name == "Alt+f":
|
|
355
|
+
new_session = self._cycle_session(forward=True)
|
|
356
|
+
if new_session:
|
|
357
|
+
self._current_session = new_session
|
|
358
|
+
return False
|
|
359
|
+
elif key_press.name == "Alt+b":
|
|
360
|
+
new_session = self._cycle_session(forward=False)
|
|
361
|
+
if new_session:
|
|
362
|
+
self._current_session = new_session
|
|
363
|
+
return False
|
|
364
|
+
|
|
365
|
+
# Only forward keys if we have an active session
|
|
366
|
+
if self._current_session is None:
|
|
367
|
+
return False
|
|
368
|
+
|
|
369
|
+
# Forward arrow keys to tmux
|
|
370
|
+
if key_press.name in ("ArrowLeft", "ArrowRight"):
|
|
371
|
+
self._send_keys_to_tmux(self._current_session, key_press.name.replace("Arrow", ""))
|
|
372
|
+
return False
|
|
373
|
+
|
|
374
|
+
# Handle Ctrl+C - send to tmux (interrupt)
|
|
375
|
+
if key_press.char and ord(key_press.char) == 3:
|
|
376
|
+
self._send_keys_to_tmux(self._current_session, "C-c")
|
|
377
|
+
return False
|
|
378
|
+
|
|
379
|
+
# Map special keys to tmux key names
|
|
380
|
+
key_map = {
|
|
381
|
+
"Enter": "Enter",
|
|
382
|
+
"Backspace": "BSpace",
|
|
383
|
+
"Tab": "Tab",
|
|
384
|
+
"ArrowUp": "Up",
|
|
385
|
+
"ArrowDown": "Down",
|
|
386
|
+
"Home": "Home",
|
|
387
|
+
"End": "End",
|
|
388
|
+
"PageUp": "PageUp",
|
|
389
|
+
"PageDown": "PageDown",
|
|
390
|
+
"Delete": "DC",
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
# Forward special keys
|
|
394
|
+
if key_press.name in key_map:
|
|
395
|
+
self._send_keys_to_tmux(self._current_session, key_map[key_press.name])
|
|
396
|
+
# Forward regular character keys
|
|
397
|
+
elif key_press.char:
|
|
398
|
+
self._send_keys_to_tmux(self._current_session, key_press.char)
|
|
399
|
+
|
|
400
|
+
return False
|
|
401
|
+
|
|
402
|
+
# Configure the live modal with streaming-friendly refresh rate
|
|
403
|
+
config = LiveModalConfig(
|
|
404
|
+
title="terminal",
|
|
405
|
+
footer="Esc: exit | Opt+Left/Right: cycle | Opt+x: kills session",
|
|
406
|
+
refresh_rate=self.config.get("plugins.tmux.refresh_rate", 2.0), # 2 seconds - much slower
|
|
407
|
+
passthrough_input=True
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
# Store current session
|
|
411
|
+
self._current_session = session_name
|
|
412
|
+
|
|
413
|
+
# Emit event to trigger live modal (input handler will handle it)
|
|
414
|
+
await self.event_bus.emit_with_hooks(
|
|
415
|
+
EventType.LIVE_MODAL_TRIGGER,
|
|
416
|
+
{
|
|
417
|
+
"content_generator": get_tmux_content,
|
|
418
|
+
"config": config,
|
|
419
|
+
"input_callback": handle_input
|
|
420
|
+
},
|
|
421
|
+
"live_modal"
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
return CommandResult(
|
|
425
|
+
success=True,
|
|
426
|
+
message="", # No message needed, modal takes over
|
|
427
|
+
display_type="none"
|
|
428
|
+
)
|
|
429
|
+
else:
|
|
430
|
+
return CommandResult(
|
|
431
|
+
success=False,
|
|
432
|
+
message="Live view not available - event bus not configured",
|
|
433
|
+
display_type="error"
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
def _capture_tmux_pane(self, session_name: str) -> List[str]:
|
|
437
|
+
"""Capture current content of a tmux pane."""
|
|
438
|
+
try:
|
|
439
|
+
# Capture only the last 5 lines to reduce overhead
|
|
440
|
+
result = subprocess.run(
|
|
441
|
+
["tmux", "capture-pane", "-p", "-S", "-5", "-t", session_name],
|
|
442
|
+
capture_output=True,
|
|
443
|
+
text=True,
|
|
444
|
+
timeout=2 # Add timeout to prevent hanging
|
|
445
|
+
)
|
|
446
|
+
if result.returncode == 0:
|
|
447
|
+
# Split on \n and handle empty lines properly
|
|
448
|
+
content = result.stdout
|
|
449
|
+
# Remove trailing empty lines but keep internal empty lines
|
|
450
|
+
while content.endswith('\n'):
|
|
451
|
+
content = content[:-1]
|
|
452
|
+
|
|
453
|
+
lines = content.split('\n')
|
|
454
|
+
|
|
455
|
+
# Clean up captured content for display
|
|
456
|
+
cleaned = []
|
|
457
|
+
for line in lines:
|
|
458
|
+
# Strip ANSI escape sequences and trailing whitespace
|
|
459
|
+
import re
|
|
460
|
+
line = re.sub(r'\x1b\[[0-9;]*m', '', line) # Remove ANSI colors
|
|
461
|
+
line = line.rstrip()
|
|
462
|
+
# Only add non-empty lines or empty lines that have meaningful content
|
|
463
|
+
if line or (cleaned and cleaned[-1] and not cleaned[-1].isspace()):
|
|
464
|
+
cleaned.append(line)
|
|
465
|
+
return cleaned
|
|
466
|
+
else:
|
|
467
|
+
return [f"Error capturing pane: {result.stderr}"]
|
|
468
|
+
except subprocess.TimeoutExpired:
|
|
469
|
+
return [f"Session capture timed out - session might be unresponsive"]
|
|
470
|
+
except Exception as e:
|
|
471
|
+
return [f"Error: {e}"]
|
|
472
|
+
|
|
473
|
+
def _send_keys_to_tmux(self, session_name: str, keys: str):
|
|
474
|
+
"""Send keys to a tmux session."""
|
|
475
|
+
try:
|
|
476
|
+
subprocess.run(
|
|
477
|
+
["tmux", "send-keys", "-t", session_name, keys],
|
|
478
|
+
capture_output=True
|
|
479
|
+
)
|
|
480
|
+
except Exception as e:
|
|
481
|
+
self.logger.error(f"Error sending keys to tmux: {e}")
|
|
482
|
+
|
|
483
|
+
async def _handle_list_sessions(self) -> "CommandResult":
|
|
484
|
+
"""List all tmux sessions (both managed and discovered)."""
|
|
485
|
+
from core.events.models import CommandResult
|
|
486
|
+
|
|
487
|
+
# Discover all existing tmux sessions
|
|
488
|
+
all_sessions = self._get_all_tmux_sessions()
|
|
489
|
+
|
|
490
|
+
if not all_sessions:
|
|
491
|
+
return CommandResult(
|
|
492
|
+
success=True,
|
|
493
|
+
message="No tmux sessions found. Use '/tmux new <name> <command>' to create one.",
|
|
494
|
+
display_type="info"
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
# Build list of sessions, one per line
|
|
498
|
+
lines = ["Terminal Sessions:"]
|
|
499
|
+
for session_name in all_sessions:
|
|
500
|
+
# Check if it's a managed session
|
|
501
|
+
if session_name in self.sessions:
|
|
502
|
+
session = self.sessions[session_name]
|
|
503
|
+
status = "[MANAGED]" if session.is_alive() else "[MANAGED-DEAD]"
|
|
504
|
+
lines.append(f"{status} {session_name}")
|
|
505
|
+
else:
|
|
506
|
+
lines.append(f"[EXTERNAL] {session_name}")
|
|
507
|
+
|
|
508
|
+
message = "\n".join(lines)
|
|
509
|
+
|
|
510
|
+
return CommandResult(
|
|
511
|
+
success=True,
|
|
512
|
+
message=message,
|
|
513
|
+
display_type="info"
|
|
514
|
+
)
|
|
515
|
+
|
|
516
|
+
async def _handle_kill_session(self, args: List[str]) -> "CommandResult":
|
|
517
|
+
"""Kill a tmux session."""
|
|
518
|
+
from core.events.models import CommandResult
|
|
519
|
+
|
|
520
|
+
if not args:
|
|
521
|
+
return CommandResult(
|
|
522
|
+
success=False,
|
|
523
|
+
message="Usage: /tmux kill <session_name>",
|
|
524
|
+
display_type="error"
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
session_name = args[0]
|
|
528
|
+
|
|
529
|
+
try:
|
|
530
|
+
result = subprocess.run(
|
|
531
|
+
["tmux", "kill-session", "-t", session_name],
|
|
532
|
+
capture_output=True,
|
|
533
|
+
text=True
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
if result.returncode == 0:
|
|
537
|
+
# Remove from tracking
|
|
538
|
+
if session_name in self.sessions:
|
|
539
|
+
del self.sessions[session_name]
|
|
540
|
+
return CommandResult(
|
|
541
|
+
success=True,
|
|
542
|
+
message=f"Killed session '{session_name}'",
|
|
543
|
+
display_type="info"
|
|
544
|
+
)
|
|
545
|
+
else:
|
|
546
|
+
return CommandResult(
|
|
547
|
+
success=False,
|
|
548
|
+
message=f"Failed to kill session: {result.stderr}",
|
|
549
|
+
display_type="error"
|
|
550
|
+
)
|
|
551
|
+
|
|
552
|
+
except Exception as e:
|
|
553
|
+
return CommandResult(
|
|
554
|
+
success=False,
|
|
555
|
+
message=f"Error killing session: {e}",
|
|
556
|
+
display_type="error"
|
|
557
|
+
)
|
|
558
|
+
|
|
559
|
+
async def _handle_attach_session(self, args: List[str]) -> "CommandResult":
|
|
560
|
+
"""Attach to a tmux session (exits kollabor)."""
|
|
561
|
+
from core.events.models import CommandResult
|
|
562
|
+
|
|
563
|
+
if not args:
|
|
564
|
+
return CommandResult(
|
|
565
|
+
success=False,
|
|
566
|
+
message="Usage: /tmux attach <session_name>",
|
|
567
|
+
display_type="error"
|
|
568
|
+
)
|
|
569
|
+
|
|
570
|
+
session_name = args[0]
|
|
571
|
+
|
|
572
|
+
if not self._session_exists(session_name):
|
|
573
|
+
return CommandResult(
|
|
574
|
+
success=False,
|
|
575
|
+
message=f"Session '{session_name}' not found",
|
|
576
|
+
display_type="error"
|
|
577
|
+
)
|
|
578
|
+
|
|
579
|
+
return CommandResult(
|
|
580
|
+
success=True,
|
|
581
|
+
message=f"To attach to '{session_name}', exit kollabor and run:\n tmux attach -t {session_name}",
|
|
582
|
+
display_type="info"
|
|
583
|
+
)
|
|
584
|
+
|
|
585
|
+
def _session_exists(self, session_name: str) -> bool:
|
|
586
|
+
"""Check if a tmux session exists."""
|
|
587
|
+
try:
|
|
588
|
+
result = subprocess.run(
|
|
589
|
+
["tmux", "has-session", "-t", session_name],
|
|
590
|
+
capture_output=True
|
|
591
|
+
)
|
|
592
|
+
return result.returncode == 0
|
|
593
|
+
except Exception:
|
|
594
|
+
return False
|
|
595
|
+
|
|
596
|
+
def _cycle_session(self, forward: bool = True) -> Optional[str]:
|
|
597
|
+
"""Cycle to next/previous tmux session.
|
|
598
|
+
|
|
599
|
+
Args:
|
|
600
|
+
forward: True for next session, False for previous.
|
|
601
|
+
|
|
602
|
+
Returns:
|
|
603
|
+
New session name, or None if no other sessions.
|
|
604
|
+
"""
|
|
605
|
+
sessions = self._get_all_tmux_sessions()
|
|
606
|
+
if len(sessions) <= 1:
|
|
607
|
+
return None
|
|
608
|
+
|
|
609
|
+
try:
|
|
610
|
+
current_idx = sessions.index(self._current_session)
|
|
611
|
+
if forward:
|
|
612
|
+
new_idx = (current_idx + 1) % len(sessions)
|
|
613
|
+
else:
|
|
614
|
+
new_idx = (current_idx - 1) % len(sessions)
|
|
615
|
+
return sessions[new_idx]
|
|
616
|
+
except ValueError:
|
|
617
|
+
# Current session not in list, return first
|
|
618
|
+
return sessions[0] if sessions else None
|
|
619
|
+
|
|
620
|
+
def _refresh_session_status(self):
|
|
621
|
+
"""Refresh status of all tracked sessions."""
|
|
622
|
+
dead_sessions = []
|
|
623
|
+
for name, session in self.sessions.items():
|
|
624
|
+
if not session.is_alive():
|
|
625
|
+
dead_sessions.append(name)
|
|
626
|
+
|
|
627
|
+
# Optionally remove dead sessions from tracking
|
|
628
|
+
# For now, keep them but show as [DEAD]
|
|
629
|
+
|
|
630
|
+
def _get_all_tmux_sessions(self) -> List[str]:
|
|
631
|
+
"""Get list of all existing tmux sessions."""
|
|
632
|
+
try:
|
|
633
|
+
result = subprocess.run(
|
|
634
|
+
["tmux", "list-sessions", "-F", "#{session_name}"],
|
|
635
|
+
capture_output=True,
|
|
636
|
+
text=True,
|
|
637
|
+
check=True
|
|
638
|
+
)
|
|
639
|
+
if result.returncode == 0:
|
|
640
|
+
# Parse output: each line is a session name
|
|
641
|
+
sessions = []
|
|
642
|
+
for line in result.stdout.strip().split('\n'):
|
|
643
|
+
if line:
|
|
644
|
+
sessions.append(line)
|
|
645
|
+
return sessions
|
|
646
|
+
return []
|
|
647
|
+
except subprocess.CalledProcessError:
|
|
648
|
+
# No sessions or tmux not running
|
|
649
|
+
return []
|
|
650
|
+
except Exception as e:
|
|
651
|
+
self.logger.error(f"Error listing tmux sessions: {e}")
|
|
652
|
+
return []
|
|
653
|
+
|
|
654
|
+
def _discover_existing_sessions(self):
|
|
655
|
+
"""Discover existing tmux sessions (optional bootstrap)."""
|
|
656
|
+
try:
|
|
657
|
+
result = subprocess.run(
|
|
658
|
+
["tmux", "list-sessions", "-F", "#{session_name}"],
|
|
659
|
+
capture_output=True,
|
|
660
|
+
text=True
|
|
661
|
+
)
|
|
662
|
+
if result.returncode == 0:
|
|
663
|
+
existing = result.stdout.strip().split("\n")
|
|
664
|
+
self.logger.debug(f"Found existing tmux sessions: {existing}")
|
|
665
|
+
except Exception:
|
|
666
|
+
pass
|
|
667
|
+
|
|
668
|
+
async def _register_status_view(self) -> None:
|
|
669
|
+
"""Register tmux sessions status view."""
|
|
670
|
+
try:
|
|
671
|
+
# Check if renderer has status registry
|
|
672
|
+
if (hasattr(self.renderer, 'status_renderer') and
|
|
673
|
+
self.renderer.status_renderer and
|
|
674
|
+
hasattr(self.renderer.status_renderer, 'status_registry') and
|
|
675
|
+
self.renderer.status_renderer.status_registry):
|
|
676
|
+
|
|
677
|
+
from core.io.status_renderer import StatusViewConfig, BlockConfig
|
|
678
|
+
|
|
679
|
+
# Create tmux sessions view
|
|
680
|
+
tmux_view = StatusViewConfig(
|
|
681
|
+
name="Tmux Sessions",
|
|
682
|
+
plugin_source="tmux",
|
|
683
|
+
priority=500, # Between core views and plugin views
|
|
684
|
+
blocks=[
|
|
685
|
+
BlockConfig(
|
|
686
|
+
width_fraction=1.0,
|
|
687
|
+
content_provider=self._get_tmux_sessions_content,
|
|
688
|
+
title="Tmux Sessions",
|
|
689
|
+
priority=100
|
|
690
|
+
)
|
|
691
|
+
],
|
|
692
|
+
)
|
|
693
|
+
|
|
694
|
+
registry = self.renderer.status_renderer.status_registry
|
|
695
|
+
registry.register_status_view("tmux", tmux_view)
|
|
696
|
+
logger.info("Registered 'Tmux Sessions' status view")
|
|
697
|
+
|
|
698
|
+
else:
|
|
699
|
+
logger.debug("Status registry not available - cannot register status view")
|
|
700
|
+
|
|
701
|
+
except Exception as e:
|
|
702
|
+
logger.error(f"Failed to register tmux status view: {e}")
|
|
703
|
+
|
|
704
|
+
def _get_tmux_sessions_content(self) -> List[str]:
|
|
705
|
+
"""Get tmux sessions content for status view."""
|
|
706
|
+
try:
|
|
707
|
+
if not self.enabled:
|
|
708
|
+
return ["Tmux: Disabled"]
|
|
709
|
+
|
|
710
|
+
# Get all tmux sessions (both managed and discovered)
|
|
711
|
+
all_sessions = self._get_all_tmux_sessions()
|
|
712
|
+
|
|
713
|
+
if not all_sessions:
|
|
714
|
+
return ["Sessions: 0 active"]
|
|
715
|
+
|
|
716
|
+
lines = []
|
|
717
|
+
session_count = len(all_sessions)
|
|
718
|
+
|
|
719
|
+
# Header line
|
|
720
|
+
lines.append(f"Sessions: {session_count} active | /terminal to view")
|
|
721
|
+
|
|
722
|
+
# Display sessions in 3-column layout
|
|
723
|
+
max_display = 9 # Show max 9 sessions (3 rows x 3 columns)
|
|
724
|
+
display_sessions = all_sessions[:max_display]
|
|
725
|
+
|
|
726
|
+
# Group into rows of 3
|
|
727
|
+
rows = []
|
|
728
|
+
for i in range(0, len(display_sessions), 3):
|
|
729
|
+
row = display_sessions[i:i+3]
|
|
730
|
+
rows.append(row)
|
|
731
|
+
|
|
732
|
+
# Format each row
|
|
733
|
+
for row in rows:
|
|
734
|
+
# Pad row to 3 columns
|
|
735
|
+
while len(row) < 3:
|
|
736
|
+
row.append("")
|
|
737
|
+
|
|
738
|
+
# Format with fixed width columns
|
|
739
|
+
formatted_row = f"{row[0]:<30}{row[1]:<30}{row[2]:<30}"
|
|
740
|
+
lines.append(formatted_row.rstrip())
|
|
741
|
+
|
|
742
|
+
# Show overflow count if there are more sessions
|
|
743
|
+
if session_count > max_display:
|
|
744
|
+
overflow = session_count - max_display
|
|
745
|
+
lines.append(f"({overflow} more...)")
|
|
746
|
+
|
|
747
|
+
return lines
|
|
748
|
+
|
|
749
|
+
except Exception as e:
|
|
750
|
+
logger.error(f"Error getting tmux sessions content: {e}")
|
|
751
|
+
return ["Tmux: Error"]
|
|
752
|
+
|
|
753
|
+
def get_status_line(self) -> Dict[str, List[str]]:
|
|
754
|
+
"""Get status line (no longer used - using status view instead)."""
|
|
755
|
+
# Return empty - we use the dedicated status view now
|
|
756
|
+
return {"A": [], "B": [], "C": []}
|
|
757
|
+
|
|
758
|
+
async def shutdown(self) -> None:
|
|
759
|
+
"""Shutdown the plugin."""
|
|
760
|
+
try:
|
|
761
|
+
# Clear session tracking
|
|
762
|
+
self._current_session = None
|
|
763
|
+
self.logger.info("Tmux plugin shutdown completed")
|
|
764
|
+
|
|
765
|
+
except Exception as e:
|
|
766
|
+
self.logger.error(f"Error shutting down tmux plugin: {e}")
|
|
767
|
+
|
|
768
|
+
async def register_hooks(self) -> None:
|
|
769
|
+
"""Register event hooks."""
|
|
770
|
+
# Could register hooks for live modal input handling
|
|
771
|
+
pass
|
|
772
|
+
|
|
773
|
+
@staticmethod
|
|
774
|
+
def get_config_widgets() -> Optional[Dict[str, Any]]:
|
|
775
|
+
"""Get configuration widgets for the config modal."""
|
|
776
|
+
return {
|
|
777
|
+
"title": "Tmux Settings",
|
|
778
|
+
"widgets": [
|
|
779
|
+
{
|
|
780
|
+
"type": "checkbox",
|
|
781
|
+
"label": "Show Status",
|
|
782
|
+
"config_path": "plugins.tmux.show_status",
|
|
783
|
+
"help": "Show tmux session count in status bar"
|
|
784
|
+
},
|
|
785
|
+
{
|
|
786
|
+
"type": "slider",
|
|
787
|
+
"label": "Refresh Rate (ms)",
|
|
788
|
+
"config_path": "plugins.tmux.refresh_rate_ms",
|
|
789
|
+
"min_value": 50,
|
|
790
|
+
"max_value": 1000,
|
|
791
|
+
"step": 50,
|
|
792
|
+
"help": "Live view refresh rate in milliseconds"
|
|
793
|
+
}
|
|
794
|
+
]
|
|
795
|
+
}
|