superqode 0.1.5__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.
- superqode/__init__.py +33 -0
- superqode/acp/__init__.py +23 -0
- superqode/acp/client.py +913 -0
- superqode/acp/permission_screen.py +457 -0
- superqode/acp/types.py +480 -0
- superqode/acp_discovery.py +856 -0
- superqode/agent/__init__.py +22 -0
- superqode/agent/edit_strategies.py +334 -0
- superqode/agent/loop.py +892 -0
- superqode/agent/qe_report_templates.py +39 -0
- superqode/agent/system_prompts.py +353 -0
- superqode/agent_output.py +721 -0
- superqode/agent_stream.py +953 -0
- superqode/agents/__init__.py +59 -0
- superqode/agents/acp_registry.py +305 -0
- superqode/agents/client.py +249 -0
- superqode/agents/data/augmentcode.com.toml +51 -0
- superqode/agents/data/cagent.dev.toml +51 -0
- superqode/agents/data/claude.com.toml +60 -0
- superqode/agents/data/codeassistant.dev.toml +51 -0
- superqode/agents/data/codex.openai.com.toml +57 -0
- superqode/agents/data/fastagent.ai.toml +66 -0
- superqode/agents/data/geminicli.com.toml +77 -0
- superqode/agents/data/goose.block.xyz.toml +54 -0
- superqode/agents/data/junie.jetbrains.com.toml +56 -0
- superqode/agents/data/kimi.moonshot.cn.toml +57 -0
- superqode/agents/data/llmlingagent.dev.toml +51 -0
- superqode/agents/data/molt.bot.toml +49 -0
- superqode/agents/data/opencode.ai.toml +60 -0
- superqode/agents/data/stakpak.dev.toml +51 -0
- superqode/agents/data/vtcode.dev.toml +51 -0
- superqode/agents/discovery.py +266 -0
- superqode/agents/messaging.py +160 -0
- superqode/agents/persona.py +166 -0
- superqode/agents/registry.py +421 -0
- superqode/agents/schema.py +72 -0
- superqode/agents/unified.py +367 -0
- superqode/app/__init__.py +111 -0
- superqode/app/constants.py +314 -0
- superqode/app/css.py +366 -0
- superqode/app/models.py +118 -0
- superqode/app/suggester.py +125 -0
- superqode/app/widgets.py +1591 -0
- superqode/app_enhanced.py +399 -0
- superqode/app_main.py +17187 -0
- superqode/approval.py +312 -0
- superqode/atomic.py +296 -0
- superqode/commands/__init__.py +1 -0
- superqode/commands/acp.py +965 -0
- superqode/commands/agents.py +180 -0
- superqode/commands/auth.py +278 -0
- superqode/commands/config.py +374 -0
- superqode/commands/init.py +826 -0
- superqode/commands/providers.py +819 -0
- superqode/commands/qe.py +1145 -0
- superqode/commands/roles.py +380 -0
- superqode/commands/serve.py +172 -0
- superqode/commands/suggestions.py +127 -0
- superqode/commands/superqe.py +460 -0
- superqode/config/__init__.py +51 -0
- superqode/config/loader.py +812 -0
- superqode/config/schema.py +498 -0
- superqode/core/__init__.py +111 -0
- superqode/core/roles.py +281 -0
- superqode/danger.py +386 -0
- superqode/data/superqode-template.yaml +1522 -0
- superqode/design_system.py +1080 -0
- superqode/dialogs/__init__.py +6 -0
- superqode/dialogs/base.py +39 -0
- superqode/dialogs/model.py +130 -0
- superqode/dialogs/provider.py +870 -0
- superqode/diff_view.py +919 -0
- superqode/enterprise.py +21 -0
- superqode/evaluation/__init__.py +25 -0
- superqode/evaluation/adapters.py +93 -0
- superqode/evaluation/behaviors.py +89 -0
- superqode/evaluation/engine.py +209 -0
- superqode/evaluation/scenarios.py +96 -0
- superqode/execution/__init__.py +36 -0
- superqode/execution/linter.py +538 -0
- superqode/execution/modes.py +347 -0
- superqode/execution/resolver.py +283 -0
- superqode/execution/runner.py +642 -0
- superqode/file_explorer.py +811 -0
- superqode/file_viewer.py +471 -0
- superqode/flash.py +183 -0
- superqode/guidance/__init__.py +58 -0
- superqode/guidance/config.py +203 -0
- superqode/guidance/prompts.py +71 -0
- superqode/harness/__init__.py +54 -0
- superqode/harness/accelerator.py +291 -0
- superqode/harness/config.py +319 -0
- superqode/harness/validator.py +147 -0
- superqode/history.py +279 -0
- superqode/integrations/superopt_runner.py +124 -0
- superqode/logging/__init__.py +49 -0
- superqode/logging/adapters.py +219 -0
- superqode/logging/formatter.py +923 -0
- superqode/logging/integration.py +341 -0
- superqode/logging/sinks.py +170 -0
- superqode/logging/unified_log.py +417 -0
- superqode/lsp/__init__.py +26 -0
- superqode/lsp/client.py +544 -0
- superqode/main.py +1069 -0
- superqode/mcp/__init__.py +89 -0
- superqode/mcp/auth_storage.py +380 -0
- superqode/mcp/client.py +1236 -0
- superqode/mcp/config.py +319 -0
- superqode/mcp/integration.py +337 -0
- superqode/mcp/oauth.py +436 -0
- superqode/mcp/oauth_callback.py +385 -0
- superqode/mcp/types.py +290 -0
- superqode/memory/__init__.py +31 -0
- superqode/memory/feedback.py +342 -0
- superqode/memory/store.py +522 -0
- superqode/notifications.py +369 -0
- superqode/optimization/__init__.py +5 -0
- superqode/optimization/config.py +33 -0
- superqode/permissions/__init__.py +25 -0
- superqode/permissions/rules.py +488 -0
- superqode/plan.py +323 -0
- superqode/providers/__init__.py +33 -0
- superqode/providers/gateway/__init__.py +165 -0
- superqode/providers/gateway/base.py +228 -0
- superqode/providers/gateway/litellm_gateway.py +1170 -0
- superqode/providers/gateway/openresponses_gateway.py +436 -0
- superqode/providers/health.py +297 -0
- superqode/providers/huggingface/__init__.py +74 -0
- superqode/providers/huggingface/downloader.py +472 -0
- superqode/providers/huggingface/endpoints.py +442 -0
- superqode/providers/huggingface/hub.py +531 -0
- superqode/providers/huggingface/inference.py +394 -0
- superqode/providers/huggingface/transformers_runner.py +516 -0
- superqode/providers/local/__init__.py +100 -0
- superqode/providers/local/base.py +438 -0
- superqode/providers/local/discovery.py +418 -0
- superqode/providers/local/lmstudio.py +256 -0
- superqode/providers/local/mlx.py +457 -0
- superqode/providers/local/ollama.py +486 -0
- superqode/providers/local/sglang.py +268 -0
- superqode/providers/local/tgi.py +260 -0
- superqode/providers/local/tool_support.py +477 -0
- superqode/providers/local/vllm.py +258 -0
- superqode/providers/manager.py +1338 -0
- superqode/providers/models.py +1016 -0
- superqode/providers/models_dev.py +578 -0
- superqode/providers/openresponses/__init__.py +87 -0
- superqode/providers/openresponses/converters/__init__.py +17 -0
- superqode/providers/openresponses/converters/messages.py +343 -0
- superqode/providers/openresponses/converters/tools.py +268 -0
- superqode/providers/openresponses/schema/__init__.py +56 -0
- superqode/providers/openresponses/schema/models.py +585 -0
- superqode/providers/openresponses/streaming/__init__.py +5 -0
- superqode/providers/openresponses/streaming/parser.py +338 -0
- superqode/providers/openresponses/tools/__init__.py +21 -0
- superqode/providers/openresponses/tools/apply_patch.py +352 -0
- superqode/providers/openresponses/tools/code_interpreter.py +290 -0
- superqode/providers/openresponses/tools/file_search.py +333 -0
- superqode/providers/openresponses/tools/mcp_adapter.py +252 -0
- superqode/providers/registry.py +716 -0
- superqode/providers/usage.py +332 -0
- superqode/pure_mode.py +384 -0
- superqode/qr/__init__.py +23 -0
- superqode/qr/dashboard.py +781 -0
- superqode/qr/generator.py +1018 -0
- superqode/qr/templates.py +135 -0
- superqode/safety/__init__.py +41 -0
- superqode/safety/sandbox.py +413 -0
- superqode/safety/warnings.py +256 -0
- superqode/server/__init__.py +33 -0
- superqode/server/lsp_server.py +775 -0
- superqode/server/web.py +250 -0
- superqode/session/__init__.py +25 -0
- superqode/session/persistence.py +580 -0
- superqode/session/sharing.py +477 -0
- superqode/session.py +475 -0
- superqode/sidebar.py +2991 -0
- superqode/stream_view.py +648 -0
- superqode/styles/__init__.py +3 -0
- superqode/superqe/__init__.py +184 -0
- superqode/superqe/acp_runner.py +1064 -0
- superqode/superqe/constitution/__init__.py +62 -0
- superqode/superqe/constitution/evaluator.py +308 -0
- superqode/superqe/constitution/loader.py +432 -0
- superqode/superqe/constitution/schema.py +250 -0
- superqode/superqe/events.py +591 -0
- superqode/superqe/frameworks/__init__.py +65 -0
- superqode/superqe/frameworks/base.py +234 -0
- superqode/superqe/frameworks/e2e.py +263 -0
- superqode/superqe/frameworks/executor.py +237 -0
- superqode/superqe/frameworks/javascript.py +409 -0
- superqode/superqe/frameworks/python.py +373 -0
- superqode/superqe/frameworks/registry.py +92 -0
- superqode/superqe/mcp_tools/__init__.py +47 -0
- superqode/superqe/mcp_tools/core_tools.py +418 -0
- superqode/superqe/mcp_tools/registry.py +230 -0
- superqode/superqe/mcp_tools/testing_tools.py +167 -0
- superqode/superqe/noise.py +89 -0
- superqode/superqe/orchestrator.py +778 -0
- superqode/superqe/roles.py +609 -0
- superqode/superqe/session.py +713 -0
- superqode/superqe/skills/__init__.py +57 -0
- superqode/superqe/skills/base.py +106 -0
- superqode/superqe/skills/core_skills.py +899 -0
- superqode/superqe/skills/registry.py +90 -0
- superqode/superqe/verifier.py +101 -0
- superqode/superqe_cli.py +76 -0
- superqode/tool_call.py +358 -0
- superqode/tools/__init__.py +93 -0
- superqode/tools/agent_tools.py +496 -0
- superqode/tools/base.py +324 -0
- superqode/tools/batch_tool.py +133 -0
- superqode/tools/diagnostics.py +311 -0
- superqode/tools/edit_tools.py +653 -0
- superqode/tools/enhanced_base.py +515 -0
- superqode/tools/file_tools.py +269 -0
- superqode/tools/file_tracking.py +45 -0
- superqode/tools/lsp_tools.py +610 -0
- superqode/tools/network_tools.py +350 -0
- superqode/tools/permissions.py +400 -0
- superqode/tools/question_tool.py +324 -0
- superqode/tools/search_tools.py +598 -0
- superqode/tools/shell_tools.py +259 -0
- superqode/tools/todo_tools.py +121 -0
- superqode/tools/validation.py +80 -0
- superqode/tools/web_tools.py +639 -0
- superqode/tui.py +1152 -0
- superqode/tui_integration.py +875 -0
- superqode/tui_widgets/__init__.py +27 -0
- superqode/tui_widgets/widgets/__init__.py +18 -0
- superqode/tui_widgets/widgets/progress.py +185 -0
- superqode/tui_widgets/widgets/tool_display.py +188 -0
- superqode/undo_manager.py +574 -0
- superqode/utils/__init__.py +5 -0
- superqode/utils/error_handling.py +323 -0
- superqode/utils/fuzzy.py +257 -0
- superqode/widgets/__init__.py +477 -0
- superqode/widgets/agent_collab.py +390 -0
- superqode/widgets/agent_store.py +936 -0
- superqode/widgets/agent_switcher.py +395 -0
- superqode/widgets/animation_manager.py +284 -0
- superqode/widgets/code_context.py +356 -0
- superqode/widgets/command_palette.py +412 -0
- superqode/widgets/connection_status.py +537 -0
- superqode/widgets/conversation_history.py +470 -0
- superqode/widgets/diff_indicator.py +155 -0
- superqode/widgets/enhanced_status_bar.py +385 -0
- superqode/widgets/enhanced_toast.py +476 -0
- superqode/widgets/file_browser.py +809 -0
- superqode/widgets/file_reference.py +585 -0
- superqode/widgets/issue_timeline.py +340 -0
- superqode/widgets/leader_key.py +264 -0
- superqode/widgets/mode_switcher.py +445 -0
- superqode/widgets/model_picker.py +234 -0
- superqode/widgets/permission_preview.py +1205 -0
- superqode/widgets/prompt.py +358 -0
- superqode/widgets/provider_connect.py +725 -0
- superqode/widgets/pty_shell.py +587 -0
- superqode/widgets/qe_dashboard.py +321 -0
- superqode/widgets/resizable_sidebar.py +377 -0
- superqode/widgets/response_changes.py +218 -0
- superqode/widgets/response_display.py +528 -0
- superqode/widgets/rich_tool_display.py +613 -0
- superqode/widgets/sidebar_panels.py +1180 -0
- superqode/widgets/slash_complete.py +356 -0
- superqode/widgets/split_view.py +612 -0
- superqode/widgets/status_bar.py +273 -0
- superqode/widgets/superqode_display.py +786 -0
- superqode/widgets/thinking_display.py +815 -0
- superqode/widgets/throbber.py +87 -0
- superqode/widgets/toast.py +206 -0
- superqode/widgets/unified_output.py +1073 -0
- superqode/workspace/__init__.py +75 -0
- superqode/workspace/artifacts.py +472 -0
- superqode/workspace/coordinator.py +353 -0
- superqode/workspace/diff_tracker.py +429 -0
- superqode/workspace/git_guard.py +373 -0
- superqode/workspace/git_snapshot.py +526 -0
- superqode/workspace/manager.py +750 -0
- superqode/workspace/snapshot.py +357 -0
- superqode/workspace/watcher.py +535 -0
- superqode/workspace/worktree.py +440 -0
- superqode-0.1.5.dist-info/METADATA +204 -0
- superqode-0.1.5.dist-info/RECORD +288 -0
- superqode-0.1.5.dist-info/WHEEL +5 -0
- superqode-0.1.5.dist-info/entry_points.txt +3 -0
- superqode-0.1.5.dist-info/licenses/LICENSE +648 -0
- superqode-0.1.5.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,587 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PTY Shell Widget - Interactive Terminal in TUI.
|
|
3
|
+
|
|
4
|
+
Provides a full pseudo-terminal (PTY) for running interactive
|
|
5
|
+
shell commands within the SuperQode TUI.
|
|
6
|
+
|
|
7
|
+
Features:
|
|
8
|
+
- True terminal emulation (ncurses, vim, etc.)
|
|
9
|
+
- Resize support
|
|
10
|
+
- Input/output streaming
|
|
11
|
+
- Multiple shell sessions
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import asyncio
|
|
17
|
+
import os
|
|
18
|
+
import pty
|
|
19
|
+
import select
|
|
20
|
+
import signal
|
|
21
|
+
import struct
|
|
22
|
+
import sys
|
|
23
|
+
import termios
|
|
24
|
+
import fcntl
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
from datetime import datetime
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Callable, Dict, List, Optional, Tuple
|
|
29
|
+
import threading
|
|
30
|
+
|
|
31
|
+
from rich.console import RenderableType
|
|
32
|
+
from rich.panel import Panel
|
|
33
|
+
from rich.text import Text
|
|
34
|
+
from textual.reactive import reactive
|
|
35
|
+
from textual.widgets import Static
|
|
36
|
+
from textual.timer import Timer
|
|
37
|
+
from textual import events
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class ShellSession:
|
|
42
|
+
"""A shell session with PTY."""
|
|
43
|
+
|
|
44
|
+
id: str
|
|
45
|
+
pid: int
|
|
46
|
+
fd: int
|
|
47
|
+
cwd: Path
|
|
48
|
+
created_at: datetime
|
|
49
|
+
title: str = "Shell"
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def is_running(self) -> bool:
|
|
53
|
+
"""Check if the shell process is still running."""
|
|
54
|
+
try:
|
|
55
|
+
os.kill(self.pid, 0)
|
|
56
|
+
return True
|
|
57
|
+
except OSError:
|
|
58
|
+
return False
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class PTYShell:
|
|
62
|
+
"""
|
|
63
|
+
Pseudo-terminal shell manager.
|
|
64
|
+
|
|
65
|
+
Manages shell sessions with proper PTY handling for
|
|
66
|
+
interactive terminal applications.
|
|
67
|
+
|
|
68
|
+
Usage:
|
|
69
|
+
shell = PTYShell()
|
|
70
|
+
shell.start()
|
|
71
|
+
|
|
72
|
+
# Send input
|
|
73
|
+
shell.write("ls -la\\n")
|
|
74
|
+
|
|
75
|
+
# Read output
|
|
76
|
+
output = shell.read()
|
|
77
|
+
|
|
78
|
+
# Resize
|
|
79
|
+
shell.resize(80, 24)
|
|
80
|
+
|
|
81
|
+
shell.stop()
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
DEFAULT_SHELL = os.environ.get("SHELL", "/bin/bash")
|
|
85
|
+
|
|
86
|
+
def __init__(
|
|
87
|
+
self,
|
|
88
|
+
working_directory: Optional[Path] = None,
|
|
89
|
+
shell: Optional[str] = None,
|
|
90
|
+
env: Optional[Dict[str, str]] = None,
|
|
91
|
+
):
|
|
92
|
+
self.working_directory = working_directory or Path.cwd()
|
|
93
|
+
self.shell = shell or self.DEFAULT_SHELL
|
|
94
|
+
self.env = env or dict(os.environ)
|
|
95
|
+
|
|
96
|
+
# PTY state
|
|
97
|
+
self._master_fd: Optional[int] = None
|
|
98
|
+
self._slave_fd: Optional[int] = None
|
|
99
|
+
self._pid: Optional[int] = None
|
|
100
|
+
self._running = False
|
|
101
|
+
|
|
102
|
+
# Output buffer
|
|
103
|
+
self._output_buffer: List[str] = []
|
|
104
|
+
self._output_lock = threading.Lock()
|
|
105
|
+
|
|
106
|
+
# Callbacks
|
|
107
|
+
self._on_output: Optional[Callable[[str], None]] = None
|
|
108
|
+
self._on_exit: Optional[Callable[[int], None]] = None
|
|
109
|
+
|
|
110
|
+
# Reader thread
|
|
111
|
+
self._reader_thread: Optional[threading.Thread] = None
|
|
112
|
+
|
|
113
|
+
# Terminal size
|
|
114
|
+
self._rows = 24
|
|
115
|
+
self._cols = 80
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def is_running(self) -> bool:
|
|
119
|
+
"""Check if shell is running."""
|
|
120
|
+
return self._running and self._pid is not None
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def pid(self) -> Optional[int]:
|
|
124
|
+
"""Get the shell process ID."""
|
|
125
|
+
return self._pid
|
|
126
|
+
|
|
127
|
+
def start(self) -> bool:
|
|
128
|
+
"""Start the shell session."""
|
|
129
|
+
if self._running:
|
|
130
|
+
return True
|
|
131
|
+
|
|
132
|
+
try:
|
|
133
|
+
# Create pseudo-terminal
|
|
134
|
+
self._master_fd, self._slave_fd = pty.openpty()
|
|
135
|
+
|
|
136
|
+
# Set terminal size
|
|
137
|
+
self._set_window_size(self._rows, self._cols)
|
|
138
|
+
|
|
139
|
+
# Fork process
|
|
140
|
+
self._pid = os.fork()
|
|
141
|
+
|
|
142
|
+
if self._pid == 0:
|
|
143
|
+
# Child process
|
|
144
|
+
self._child_process()
|
|
145
|
+
else:
|
|
146
|
+
# Parent process
|
|
147
|
+
os.close(self._slave_fd)
|
|
148
|
+
self._slave_fd = None
|
|
149
|
+
|
|
150
|
+
# Make master non-blocking
|
|
151
|
+
flags = fcntl.fcntl(self._master_fd, fcntl.F_GETFL)
|
|
152
|
+
fcntl.fcntl(self._master_fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
|
|
153
|
+
|
|
154
|
+
self._running = True
|
|
155
|
+
|
|
156
|
+
# Start reader thread
|
|
157
|
+
self._reader_thread = threading.Thread(
|
|
158
|
+
target=self._read_loop,
|
|
159
|
+
daemon=True,
|
|
160
|
+
)
|
|
161
|
+
self._reader_thread.start()
|
|
162
|
+
|
|
163
|
+
return True
|
|
164
|
+
|
|
165
|
+
except Exception as e:
|
|
166
|
+
self._cleanup()
|
|
167
|
+
raise RuntimeError(f"Failed to start shell: {e}")
|
|
168
|
+
|
|
169
|
+
def _child_process(self) -> None:
|
|
170
|
+
"""Set up and exec shell in child process."""
|
|
171
|
+
# Create new session
|
|
172
|
+
os.setsid()
|
|
173
|
+
|
|
174
|
+
# Set controlling terminal
|
|
175
|
+
os.dup2(self._slave_fd, 0)
|
|
176
|
+
os.dup2(self._slave_fd, 1)
|
|
177
|
+
os.dup2(self._slave_fd, 2)
|
|
178
|
+
|
|
179
|
+
if self._slave_fd > 2:
|
|
180
|
+
os.close(self._slave_fd)
|
|
181
|
+
|
|
182
|
+
# Change to working directory
|
|
183
|
+
try:
|
|
184
|
+
os.chdir(self.working_directory)
|
|
185
|
+
except OSError:
|
|
186
|
+
pass
|
|
187
|
+
|
|
188
|
+
# Set environment
|
|
189
|
+
self.env["TERM"] = "xterm-256color"
|
|
190
|
+
self.env["COLORTERM"] = "truecolor"
|
|
191
|
+
|
|
192
|
+
# Exec shell
|
|
193
|
+
try:
|
|
194
|
+
os.execvpe(self.shell, [self.shell], self.env)
|
|
195
|
+
except Exception:
|
|
196
|
+
os._exit(1)
|
|
197
|
+
|
|
198
|
+
def _set_window_size(self, rows: int, cols: int) -> None:
|
|
199
|
+
"""Set terminal window size."""
|
|
200
|
+
if self._master_fd is not None:
|
|
201
|
+
winsize = struct.pack("HHHH", rows, cols, 0, 0)
|
|
202
|
+
fcntl.ioctl(self._master_fd, termios.TIOCSWINSZ, winsize)
|
|
203
|
+
|
|
204
|
+
def _read_loop(self) -> None:
|
|
205
|
+
"""Background thread to read PTY output."""
|
|
206
|
+
while self._running:
|
|
207
|
+
try:
|
|
208
|
+
# Wait for data with timeout
|
|
209
|
+
r, _, _ = select.select([self._master_fd], [], [], 0.1)
|
|
210
|
+
|
|
211
|
+
if self._master_fd in r:
|
|
212
|
+
try:
|
|
213
|
+
data = os.read(self._master_fd, 4096)
|
|
214
|
+
if data:
|
|
215
|
+
text = data.decode("utf-8", errors="replace")
|
|
216
|
+
|
|
217
|
+
with self._output_lock:
|
|
218
|
+
self._output_buffer.append(text)
|
|
219
|
+
|
|
220
|
+
if self._on_output:
|
|
221
|
+
self._on_output(text)
|
|
222
|
+
else:
|
|
223
|
+
# EOF - shell exited
|
|
224
|
+
self._handle_exit()
|
|
225
|
+
break
|
|
226
|
+
except OSError:
|
|
227
|
+
self._handle_exit()
|
|
228
|
+
break
|
|
229
|
+
|
|
230
|
+
except (ValueError, OSError):
|
|
231
|
+
# FD closed
|
|
232
|
+
break
|
|
233
|
+
|
|
234
|
+
self._running = False
|
|
235
|
+
|
|
236
|
+
def _handle_exit(self) -> None:
|
|
237
|
+
"""Handle shell exit."""
|
|
238
|
+
self._running = False
|
|
239
|
+
|
|
240
|
+
exit_code = 0
|
|
241
|
+
if self._pid:
|
|
242
|
+
try:
|
|
243
|
+
_, status = os.waitpid(self._pid, os.WNOHANG)
|
|
244
|
+
if os.WIFEXITED(status):
|
|
245
|
+
exit_code = os.WEXITSTATUS(status)
|
|
246
|
+
except ChildProcessError:
|
|
247
|
+
pass
|
|
248
|
+
|
|
249
|
+
if self._on_exit:
|
|
250
|
+
self._on_exit(exit_code)
|
|
251
|
+
|
|
252
|
+
def write(self, data: str) -> int:
|
|
253
|
+
"""Write data to the shell."""
|
|
254
|
+
if not self._running or self._master_fd is None:
|
|
255
|
+
return 0
|
|
256
|
+
|
|
257
|
+
try:
|
|
258
|
+
return os.write(self._master_fd, data.encode("utf-8"))
|
|
259
|
+
except OSError:
|
|
260
|
+
return 0
|
|
261
|
+
|
|
262
|
+
def read(self) -> str:
|
|
263
|
+
"""Read buffered output from the shell."""
|
|
264
|
+
with self._output_lock:
|
|
265
|
+
output = "".join(self._output_buffer)
|
|
266
|
+
self._output_buffer.clear()
|
|
267
|
+
return output
|
|
268
|
+
|
|
269
|
+
def resize(self, rows: int, cols: int) -> None:
|
|
270
|
+
"""Resize the terminal."""
|
|
271
|
+
self._rows = rows
|
|
272
|
+
self._cols = cols
|
|
273
|
+
|
|
274
|
+
if self._running:
|
|
275
|
+
self._set_window_size(rows, cols)
|
|
276
|
+
|
|
277
|
+
def send_signal(self, sig: int) -> None:
|
|
278
|
+
"""Send a signal to the shell process."""
|
|
279
|
+
if self._pid:
|
|
280
|
+
try:
|
|
281
|
+
os.kill(self._pid, sig)
|
|
282
|
+
except OSError:
|
|
283
|
+
pass
|
|
284
|
+
|
|
285
|
+
def interrupt(self) -> None:
|
|
286
|
+
"""Send interrupt signal (Ctrl+C)."""
|
|
287
|
+
self.write("\x03")
|
|
288
|
+
|
|
289
|
+
def stop(self) -> None:
|
|
290
|
+
"""Stop the shell session."""
|
|
291
|
+
if not self._running:
|
|
292
|
+
return
|
|
293
|
+
|
|
294
|
+
self._running = False
|
|
295
|
+
|
|
296
|
+
# Kill the process
|
|
297
|
+
if self._pid:
|
|
298
|
+
try:
|
|
299
|
+
os.kill(self._pid, signal.SIGTERM)
|
|
300
|
+
os.waitpid(self._pid, 0)
|
|
301
|
+
except (OSError, ChildProcessError):
|
|
302
|
+
pass
|
|
303
|
+
|
|
304
|
+
self._cleanup()
|
|
305
|
+
|
|
306
|
+
def _cleanup(self) -> None:
|
|
307
|
+
"""Clean up resources."""
|
|
308
|
+
if self._master_fd is not None:
|
|
309
|
+
try:
|
|
310
|
+
os.close(self._master_fd)
|
|
311
|
+
except OSError:
|
|
312
|
+
pass
|
|
313
|
+
self._master_fd = None
|
|
314
|
+
|
|
315
|
+
if self._slave_fd is not None:
|
|
316
|
+
try:
|
|
317
|
+
os.close(self._slave_fd)
|
|
318
|
+
except OSError:
|
|
319
|
+
pass
|
|
320
|
+
self._slave_fd = None
|
|
321
|
+
|
|
322
|
+
self._pid = None
|
|
323
|
+
|
|
324
|
+
def on_output(self, callback: Callable[[str], None]) -> None:
|
|
325
|
+
"""Set callback for output events."""
|
|
326
|
+
self._on_output = callback
|
|
327
|
+
|
|
328
|
+
def on_exit(self, callback: Callable[[int], None]) -> None:
|
|
329
|
+
"""Set callback for exit events."""
|
|
330
|
+
self._on_exit = callback
|
|
331
|
+
|
|
332
|
+
def __enter__(self) -> "PTYShell":
|
|
333
|
+
self.start()
|
|
334
|
+
return self
|
|
335
|
+
|
|
336
|
+
def __exit__(self, *args) -> None:
|
|
337
|
+
self.stop()
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
class PTYShellWidget(Static):
|
|
341
|
+
"""
|
|
342
|
+
Textual widget for PTY shell.
|
|
343
|
+
|
|
344
|
+
Displays an interactive terminal within the TUI.
|
|
345
|
+
|
|
346
|
+
Usage:
|
|
347
|
+
shell_widget = PTYShellWidget(working_directory=Path.cwd())
|
|
348
|
+
# Add to your app's compose()
|
|
349
|
+
"""
|
|
350
|
+
|
|
351
|
+
DEFAULT_CSS = """
|
|
352
|
+
PTYShellWidget {
|
|
353
|
+
height: 100%;
|
|
354
|
+
border: solid #3f3f46;
|
|
355
|
+
background: #0f0f0f;
|
|
356
|
+
padding: 0 1;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
PTYShellWidget:focus {
|
|
360
|
+
border: solid #3b82f6;
|
|
361
|
+
}
|
|
362
|
+
"""
|
|
363
|
+
|
|
364
|
+
# Reactive state
|
|
365
|
+
is_active: reactive[bool] = reactive(False)
|
|
366
|
+
|
|
367
|
+
def __init__(
|
|
368
|
+
self,
|
|
369
|
+
working_directory: Optional[Path] = None,
|
|
370
|
+
shell: Optional[str] = None,
|
|
371
|
+
title: str = "Terminal",
|
|
372
|
+
**kwargs,
|
|
373
|
+
):
|
|
374
|
+
super().__init__(**kwargs)
|
|
375
|
+
self.title = title
|
|
376
|
+
self._shell = PTYShell(
|
|
377
|
+
working_directory=working_directory,
|
|
378
|
+
shell=shell,
|
|
379
|
+
)
|
|
380
|
+
self._output_lines: List[str] = []
|
|
381
|
+
self._max_lines = 1000
|
|
382
|
+
self._scroll_offset = 0
|
|
383
|
+
self._timer: Optional[Timer] = None
|
|
384
|
+
|
|
385
|
+
def on_mount(self) -> None:
|
|
386
|
+
"""Start shell when mounted."""
|
|
387
|
+
# Set up callbacks
|
|
388
|
+
self._shell.on_output(self._handle_output)
|
|
389
|
+
self._shell.on_exit(self._handle_exit)
|
|
390
|
+
|
|
391
|
+
# Start shell
|
|
392
|
+
try:
|
|
393
|
+
self._shell.start()
|
|
394
|
+
self.is_active = True
|
|
395
|
+
except RuntimeError as e:
|
|
396
|
+
self._output_lines.append(f"[ERROR] {e}")
|
|
397
|
+
|
|
398
|
+
# Start refresh timer
|
|
399
|
+
self._timer = self.set_interval(0.1, self._refresh_output)
|
|
400
|
+
|
|
401
|
+
def on_unmount(self) -> None:
|
|
402
|
+
"""Stop shell when unmounted."""
|
|
403
|
+
if self._timer:
|
|
404
|
+
self._timer.stop()
|
|
405
|
+
self._shell.stop()
|
|
406
|
+
|
|
407
|
+
def on_resize(self, event: events.Resize) -> None:
|
|
408
|
+
"""Handle resize events."""
|
|
409
|
+
# Account for borders and padding
|
|
410
|
+
rows = max(1, event.size.height - 2)
|
|
411
|
+
cols = max(1, event.size.width - 4)
|
|
412
|
+
self._shell.resize(rows, cols)
|
|
413
|
+
|
|
414
|
+
def on_key(self, event: events.Key) -> None:
|
|
415
|
+
"""Handle key events."""
|
|
416
|
+
if not self.is_active:
|
|
417
|
+
return
|
|
418
|
+
|
|
419
|
+
# Convert key to terminal sequence
|
|
420
|
+
key_map = {
|
|
421
|
+
"enter": "\r",
|
|
422
|
+
"tab": "\t",
|
|
423
|
+
"backspace": "\x7f",
|
|
424
|
+
"delete": "\x1b[3~",
|
|
425
|
+
"escape": "\x1b",
|
|
426
|
+
"up": "\x1b[A",
|
|
427
|
+
"down": "\x1b[B",
|
|
428
|
+
"right": "\x1b[C",
|
|
429
|
+
"left": "\x1b[D",
|
|
430
|
+
"home": "\x1b[H",
|
|
431
|
+
"end": "\x1b[F",
|
|
432
|
+
"pageup": "\x1b[5~",
|
|
433
|
+
"pagedown": "\x1b[6~",
|
|
434
|
+
"f1": "\x1bOP",
|
|
435
|
+
"f2": "\x1bOQ",
|
|
436
|
+
"f3": "\x1bOR",
|
|
437
|
+
"f4": "\x1bOS",
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
# Ctrl key combinations
|
|
441
|
+
if event.key.startswith("ctrl+"):
|
|
442
|
+
char = event.key[5:]
|
|
443
|
+
if len(char) == 1:
|
|
444
|
+
code = ord(char.upper()) - 64
|
|
445
|
+
if 1 <= code <= 26:
|
|
446
|
+
self._shell.write(chr(code))
|
|
447
|
+
event.prevent_default()
|
|
448
|
+
return
|
|
449
|
+
|
|
450
|
+
# Special keys
|
|
451
|
+
if event.key in key_map:
|
|
452
|
+
self._shell.write(key_map[event.key])
|
|
453
|
+
event.prevent_default()
|
|
454
|
+
return
|
|
455
|
+
|
|
456
|
+
# Regular characters
|
|
457
|
+
if event.character and len(event.character) == 1:
|
|
458
|
+
self._shell.write(event.character)
|
|
459
|
+
event.prevent_default()
|
|
460
|
+
|
|
461
|
+
def _handle_output(self, text: str) -> None:
|
|
462
|
+
"""Handle output from shell."""
|
|
463
|
+
# Split into lines and add to buffer
|
|
464
|
+
lines = text.split("\n")
|
|
465
|
+
|
|
466
|
+
for i, line in enumerate(lines):
|
|
467
|
+
if i == 0 and self._output_lines:
|
|
468
|
+
# Append to last line
|
|
469
|
+
self._output_lines[-1] += line
|
|
470
|
+
else:
|
|
471
|
+
self._output_lines.append(line)
|
|
472
|
+
|
|
473
|
+
# Limit buffer size
|
|
474
|
+
if len(self._output_lines) > self._max_lines:
|
|
475
|
+
self._output_lines = self._output_lines[-self._max_lines :]
|
|
476
|
+
|
|
477
|
+
def _handle_exit(self, exit_code: int) -> None:
|
|
478
|
+
"""Handle shell exit."""
|
|
479
|
+
self.is_active = False
|
|
480
|
+
self._output_lines.append(f"\n[Process exited with code {exit_code}]")
|
|
481
|
+
self.refresh()
|
|
482
|
+
|
|
483
|
+
def _refresh_output(self) -> None:
|
|
484
|
+
"""Refresh the display."""
|
|
485
|
+
if self.is_active:
|
|
486
|
+
self.refresh()
|
|
487
|
+
|
|
488
|
+
def send_command(self, command: str) -> None:
|
|
489
|
+
"""Send a command to the shell."""
|
|
490
|
+
if self.is_active:
|
|
491
|
+
self._shell.write(command + "\n")
|
|
492
|
+
|
|
493
|
+
def clear(self) -> None:
|
|
494
|
+
"""Clear the output buffer."""
|
|
495
|
+
self._output_lines.clear()
|
|
496
|
+
self.refresh()
|
|
497
|
+
|
|
498
|
+
def render(self) -> RenderableType:
|
|
499
|
+
"""Render the terminal output."""
|
|
500
|
+
content = Text()
|
|
501
|
+
|
|
502
|
+
# Get visible lines (based on widget height)
|
|
503
|
+
visible_lines = self._output_lines[-50:] # Show last 50 lines
|
|
504
|
+
|
|
505
|
+
for line in visible_lines:
|
|
506
|
+
# Basic ANSI code stripping for now
|
|
507
|
+
# TODO: Full ANSI parsing for colors
|
|
508
|
+
clean_line = line
|
|
509
|
+
content.append(clean_line + "\n", style="#e2e8f0")
|
|
510
|
+
|
|
511
|
+
if not self.is_active:
|
|
512
|
+
content.append("\n[Shell not running]", style="#ef4444")
|
|
513
|
+
|
|
514
|
+
border_style = "#3b82f6" if self.has_focus else "#3f3f46"
|
|
515
|
+
|
|
516
|
+
return Panel(
|
|
517
|
+
content,
|
|
518
|
+
title=f"[bold #3b82f6]{self.title}[/]",
|
|
519
|
+
border_style=border_style,
|
|
520
|
+
padding=(0, 0),
|
|
521
|
+
)
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
class ShellManager:
|
|
525
|
+
"""
|
|
526
|
+
Manages multiple shell sessions.
|
|
527
|
+
|
|
528
|
+
Usage:
|
|
529
|
+
manager = ShellManager()
|
|
530
|
+
|
|
531
|
+
# Create a new shell
|
|
532
|
+
shell_id = manager.create_shell(Path.cwd())
|
|
533
|
+
|
|
534
|
+
# Get shell
|
|
535
|
+
shell = manager.get_shell(shell_id)
|
|
536
|
+
|
|
537
|
+
# List shells
|
|
538
|
+
shells = manager.list_shells()
|
|
539
|
+
|
|
540
|
+
# Close shell
|
|
541
|
+
manager.close_shell(shell_id)
|
|
542
|
+
"""
|
|
543
|
+
|
|
544
|
+
def __init__(self):
|
|
545
|
+
self._shells: Dict[str, PTYShell] = {}
|
|
546
|
+
self._counter = 0
|
|
547
|
+
|
|
548
|
+
def create_shell(
|
|
549
|
+
self,
|
|
550
|
+
working_directory: Optional[Path] = None,
|
|
551
|
+
shell: Optional[str] = None,
|
|
552
|
+
title: str = "Shell",
|
|
553
|
+
) -> str:
|
|
554
|
+
"""Create a new shell session."""
|
|
555
|
+
self._counter += 1
|
|
556
|
+
shell_id = f"shell-{self._counter}"
|
|
557
|
+
|
|
558
|
+
pty_shell = PTYShell(
|
|
559
|
+
working_directory=working_directory,
|
|
560
|
+
shell=shell,
|
|
561
|
+
)
|
|
562
|
+
pty_shell.start()
|
|
563
|
+
|
|
564
|
+
self._shells[shell_id] = pty_shell
|
|
565
|
+
return shell_id
|
|
566
|
+
|
|
567
|
+
def get_shell(self, shell_id: str) -> Optional[PTYShell]:
|
|
568
|
+
"""Get a shell by ID."""
|
|
569
|
+
return self._shells.get(shell_id)
|
|
570
|
+
|
|
571
|
+
def close_shell(self, shell_id: str) -> bool:
|
|
572
|
+
"""Close a shell session."""
|
|
573
|
+
shell = self._shells.pop(shell_id, None)
|
|
574
|
+
if shell:
|
|
575
|
+
shell.stop()
|
|
576
|
+
return True
|
|
577
|
+
return False
|
|
578
|
+
|
|
579
|
+
def list_shells(self) -> List[str]:
|
|
580
|
+
"""List all shell IDs."""
|
|
581
|
+
return list(self._shells.keys())
|
|
582
|
+
|
|
583
|
+
def close_all(self) -> None:
|
|
584
|
+
"""Close all shell sessions."""
|
|
585
|
+
for shell in self._shells.values():
|
|
586
|
+
shell.stop()
|
|
587
|
+
self._shells.clear()
|