ptn 0.3.2__py3-none-any.whl → 0.4.6__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 (40) hide show
  1. porterminal/__init__.py +13 -5
  2. porterminal/_version.py +2 -2
  3. porterminal/application/services/management_service.py +28 -52
  4. porterminal/application/services/session_service.py +3 -11
  5. porterminal/application/services/terminal_service.py +97 -56
  6. porterminal/cli/args.py +84 -35
  7. porterminal/cli/display.py +18 -16
  8. porterminal/cli/script_discovery.py +266 -0
  9. porterminal/composition.py +2 -7
  10. porterminal/config.py +4 -2
  11. porterminal/domain/__init__.py +0 -9
  12. porterminal/domain/entities/output_buffer.py +56 -1
  13. porterminal/domain/entities/tab.py +11 -10
  14. porterminal/domain/services/__init__.py +0 -2
  15. porterminal/domain/values/__init__.py +0 -4
  16. porterminal/domain/values/environment_rules.py +3 -0
  17. porterminal/domain/values/rate_limit_config.py +3 -3
  18. porterminal/infrastructure/cloudflared.py +13 -11
  19. porterminal/infrastructure/config/shell_detector.py +113 -24
  20. porterminal/infrastructure/repositories/in_memory_session.py +1 -4
  21. porterminal/infrastructure/repositories/in_memory_tab.py +2 -10
  22. porterminal/pty/env.py +16 -78
  23. porterminal/pty/manager.py +6 -4
  24. porterminal/static/assets/app-DlWNJWFE.js +87 -0
  25. porterminal/static/assets/app-xPAM7YhQ.css +1 -0
  26. porterminal/static/index.html +2 -2
  27. porterminal/updater.py +13 -5
  28. {ptn-0.3.2.dist-info → ptn-0.4.6.dist-info}/METADATA +54 -16
  29. {ptn-0.3.2.dist-info → ptn-0.4.6.dist-info}/RECORD +32 -37
  30. porterminal/static/assets/app-BkHv5qu0.css +0 -32
  31. porterminal/static/assets/app-CaIGfw7i.js +0 -72
  32. porterminal/static/assets/app-D9ELFbEO.js +0 -72
  33. porterminal/static/assets/app-DF3nl_io.js +0 -72
  34. porterminal/static/assets/app-DQePboVd.css +0 -32
  35. porterminal/static/assets/app-DoBiVkTD.js +0 -72
  36. porterminal/static/assets/app-azbHOsRw.css +0 -32
  37. porterminal/static/assets/app-nMNFwMa6.css +0 -32
  38. {ptn-0.3.2.dist-info → ptn-0.4.6.dist-info}/WHEEL +0 -0
  39. {ptn-0.3.2.dist-info → ptn-0.4.6.dist-info}/entry_points.txt +0 -0
  40. {ptn-0.3.2.dist-info → ptn-0.4.6.dist-info}/licenses/LICENSE +0 -0
@@ -58,6 +58,14 @@ def get_caution() -> str:
58
58
  return CAUTION_DEFAULT
59
59
 
60
60
 
61
+ def _apply_gradient(lines: list[str], colors: list[str]) -> list[str]:
62
+ """Apply color gradient to text lines."""
63
+ return [
64
+ f"[{colors[min(i, len(colors) - 1)]}]{line}[/{colors[min(i, len(colors) - 1)]}]"
65
+ for i, line in enumerate(lines)
66
+ ]
67
+
68
+
61
69
  def get_qr_code(url: str) -> str:
62
70
  """Generate QR code as ASCII string.
63
71
 
@@ -112,21 +120,15 @@ def display_startup_screen(
112
120
  else:
113
121
  status = "[yellow]●[/yellow] LOCAL MODE"
114
122
 
115
- # Build logo with gradient
116
- logo_lines = LOGO.strip().split("\n")
117
- colors = ["bold bright_cyan", "bright_cyan", "cyan", "bright_blue", "blue"]
118
- logo_colored = []
119
- for i, line in enumerate(logo_lines):
120
- color = colors[i] if i < len(colors) else colors[-1]
121
- logo_colored.append(f"[{color}]{line}[/{color}]")
122
-
123
- # Build tagline with gradient
124
- tagline_lines = TAGLINE.split("\n")
125
- tagline_colors = ["bright_magenta", "magenta"]
126
- tagline_colored = []
127
- for i, line in enumerate(tagline_lines):
128
- color = tagline_colors[i] if i < len(tagline_colors) else tagline_colors[-1]
129
- tagline_colored.append(f"[{color}]{line}[/{color}]")
123
+ # Build logo and tagline with gradients
124
+ logo_colored = _apply_gradient(
125
+ LOGO.strip().split("\n"),
126
+ ["bold bright_cyan", "bright_cyan", "cyan", "bright_blue", "blue"],
127
+ )
128
+ tagline_colored = _apply_gradient(
129
+ TAGLINE.split("\n"),
130
+ ["bright_magenta", "magenta"],
131
+ )
130
132
 
131
133
  # Left side content
132
134
  left_lines = [
@@ -136,7 +138,7 @@ def display_startup_screen(
136
138
  *tagline_colored,
137
139
  "",
138
140
  f"[bold yellow]{get_caution()}[/bold yellow]",
139
- "[dim]Use -p for password protection if your screen is exposed[/dim]",
141
+ "[bright_red]Use -p for password protection if your screen is exposed[/bright_red]",
140
142
  status,
141
143
  f"[bold cyan]{url}[/bold cyan]",
142
144
  ]
@@ -0,0 +1,266 @@
1
+ """Auto-discover project scripts for config initialization."""
2
+
3
+ import json
4
+ import re
5
+ import tomllib
6
+ from pathlib import Path
7
+
8
+ import yaml
9
+
10
+ # Pattern for safe script names (alphanumeric, hyphens, underscores only)
11
+ _SAFE_NAME = re.compile(r"^[a-zA-Z0-9_-]+$")
12
+
13
+ # Maximum buttons to return from each discovery function
14
+ _MAX_BUTTONS = 6
15
+
16
+
17
+ def _is_safe_name(name: str) -> bool:
18
+ """Check if script name contains only safe characters."""
19
+ return bool(_SAFE_NAME.match(name)) and len(name) <= 50
20
+
21
+
22
+ def _find_file(base: Path, filenames: list[str]) -> Path | None:
23
+ """Find the first existing file from a list of candidates."""
24
+ for filename in filenames:
25
+ path = base / filename
26
+ if path.exists():
27
+ return path
28
+ return None
29
+
30
+
31
+ def _build_buttons(
32
+ tasks: dict,
33
+ priority: list[str],
34
+ command_prefix: str,
35
+ *,
36
+ priority_only: bool = False,
37
+ ) -> list[dict]:
38
+ """Build button configs from tasks dict with priority ordering.
39
+
40
+ Args:
41
+ tasks: Dict of task names to their definitions
42
+ priority: List of task names to prioritize
43
+ command_prefix: Command prefix (e.g., "deno task", "task", "just")
44
+ priority_only: If True, only include tasks from priority list
45
+ """
46
+ buttons = []
47
+ priority_set = set(priority)
48
+
49
+ # Add priority tasks first
50
+ for name in priority:
51
+ if name in tasks and _is_safe_name(name):
52
+ buttons.append({"label": name, "send": f"{command_prefix} {name}\r", "row": 2})
53
+
54
+ # Add remaining tasks (unless priority_only is set)
55
+ if not priority_only:
56
+ for name in tasks:
57
+ if name not in priority_set and _is_safe_name(name) and len(buttons) < _MAX_BUTTONS:
58
+ buttons.append({"label": name, "send": f"{command_prefix} {name}\r", "row": 2})
59
+
60
+ return buttons[:_MAX_BUTTONS]
61
+
62
+
63
+ def discover_scripts(cwd: Path | None = None) -> list[dict]:
64
+ """Discover project scripts in current directory.
65
+
66
+ Returns list of button configs: [{"label": "build", "send": "npm run build\\r", "row": 2}]
67
+ Only includes scripts explicitly defined in project files.
68
+ """
69
+ base = cwd or Path.cwd()
70
+ buttons = []
71
+
72
+ # Check each project type (only those with explicit scripts)
73
+ # Order matters: first match wins for deduplication
74
+ buttons.extend(_discover_npm_scripts(base)) # Also handles Bun
75
+ buttons.extend(_discover_deno_tasks(base))
76
+ buttons.extend(_discover_python_scripts(base))
77
+ buttons.extend(_discover_makefile_targets(base))
78
+ buttons.extend(_discover_just_recipes(base))
79
+ buttons.extend(_discover_taskfile_tasks(base))
80
+
81
+ # Dedupe by label, keep first occurrence
82
+ unique: dict[str, dict] = {}
83
+ for btn in buttons:
84
+ unique.setdefault(btn["label"], btn)
85
+ return list(unique.values())
86
+
87
+
88
+ def _discover_npm_scripts(base: Path) -> list[dict]:
89
+ """Extract scripts from package.json.
90
+
91
+ Uses 'bun run' if bun.lockb exists, otherwise 'npm run'.
92
+ Only includes scripts from the priority list (build, dev, start, etc.).
93
+ """
94
+ pkg_file = base / "package.json"
95
+ if not pkg_file.exists():
96
+ return []
97
+
98
+ try:
99
+ data = json.loads(pkg_file.read_text(encoding="utf-8"))
100
+ scripts = data.get("scripts", {})
101
+
102
+ # Detect package manager: bun if bun.lockb exists
103
+ runner = "bun run" if (base / "bun.lockb").exists() else "npm run"
104
+ priority = ["build", "dev", "start", "test", "lint", "format", "watch"]
105
+
106
+ return _build_buttons(scripts, priority, runner, priority_only=True)
107
+ except Exception:
108
+ return []
109
+
110
+
111
+ def _discover_python_scripts(base: Path) -> list[dict]:
112
+ """Extract scripts from pyproject.toml.
113
+
114
+ Checks [project.scripts] (PEP 621) first, then [tool.poetry.scripts].
115
+ Takes up to 4 from each source, deduplicates, and caps at 6 total.
116
+ """
117
+ toml_file = base / "pyproject.toml"
118
+ if not toml_file.exists():
119
+ return []
120
+
121
+ try:
122
+ data = tomllib.loads(toml_file.read_text(encoding="utf-8"))
123
+ buttons = []
124
+
125
+ # Check [project.scripts] (PEP 621) - take up to 4
126
+ project_scripts = data.get("project", {}).get("scripts", {})
127
+ for name in list(project_scripts.keys())[:4]:
128
+ if _is_safe_name(name):
129
+ buttons.append({"label": name, "send": f"{name}\r", "row": 2})
130
+
131
+ # Check [tool.poetry.scripts] - take up to 4, skip duplicates
132
+ existing_labels = {b["label"] for b in buttons}
133
+ poetry_scripts = data.get("tool", {}).get("poetry", {}).get("scripts", {})
134
+ for name in list(poetry_scripts.keys())[:4]:
135
+ if _is_safe_name(name) and name not in existing_labels:
136
+ buttons.append({"label": name, "send": f"{name}\r", "row": 2})
137
+
138
+ return buttons[:_MAX_BUTTONS]
139
+ except Exception:
140
+ return []
141
+
142
+
143
+ def _discover_makefile_targets(base: Path) -> list[dict]:
144
+ """Extract targets from Makefile."""
145
+ makefile = base / "Makefile"
146
+ if not makefile.exists():
147
+ return []
148
+
149
+ try:
150
+ content = makefile.read_text(encoding="utf-8")
151
+ # Match target definitions: "target:" at start of line
152
+ # Regex excludes targets starting with . (internal targets like .PHONY)
153
+ pattern = r"^([a-zA-Z_][a-zA-Z0-9_-]*)\s*:"
154
+ targets = re.findall(pattern, content, re.MULTILINE)
155
+
156
+ # Convert list to dict for _build_buttons compatibility
157
+ targets_dict = {t: True for t in targets}
158
+ priority = ["build", "test", "run", "clean", "install", "dev", "lint", "all"]
159
+
160
+ return _build_buttons(targets_dict, priority, "make")
161
+ except Exception:
162
+ return []
163
+
164
+
165
+ def _discover_deno_tasks(base: Path) -> list[dict]:
166
+ """Extract tasks from deno.json or deno.jsonc."""
167
+ deno_file = _find_file(base, ["deno.json", "deno.jsonc"])
168
+ if not deno_file:
169
+ return []
170
+
171
+ try:
172
+ content = deno_file.read_text(encoding="utf-8")
173
+ if deno_file.suffix == ".jsonc":
174
+ content = _strip_json_comments(content)
175
+
176
+ tasks = json.loads(content).get("tasks", {})
177
+ priority = ["build", "dev", "start", "test", "lint", "format", "check"]
178
+
179
+ return _build_buttons(tasks, priority, "deno task")
180
+ except Exception:
181
+ return []
182
+
183
+
184
+ def _strip_json_comments(content: str) -> str:
185
+ """Strip comments from JSON content (for .jsonc files)."""
186
+ result = []
187
+ i = 0
188
+ in_string = False
189
+ escape_next = False
190
+
191
+ while i < len(content):
192
+ char = content[i]
193
+
194
+ if escape_next:
195
+ result.append(char)
196
+ escape_next = False
197
+ i += 1
198
+ continue
199
+
200
+ if char == "\\" and in_string:
201
+ result.append(char)
202
+ escape_next = True
203
+ i += 1
204
+ continue
205
+
206
+ if char == '"' and not escape_next:
207
+ in_string = not in_string
208
+ result.append(char)
209
+ i += 1
210
+ continue
211
+
212
+ if not in_string:
213
+ # Single-line comment
214
+ if content[i : i + 2] == "//":
215
+ while i < len(content) and content[i] != "\n":
216
+ i += 1
217
+ continue
218
+ # Multi-line comment
219
+ if content[i : i + 2] == "/*":
220
+ i += 2
221
+ while i < len(content) - 1 and content[i : i + 2] != "*/":
222
+ i += 1
223
+ i += 2
224
+ continue
225
+
226
+ result.append(char)
227
+ i += 1
228
+
229
+ return "".join(result)
230
+
231
+
232
+ def _discover_just_recipes(base: Path) -> list[dict]:
233
+ """Extract recipes from justfile."""
234
+ justfile = _find_file(base, ["justfile", "Justfile", ".justfile"])
235
+ if not justfile:
236
+ return []
237
+
238
+ try:
239
+ content = justfile.read_text(encoding="utf-8")
240
+ # Match recipe definitions: "recipe:" or "recipe arg:" at start of line
241
+ # Exclude private recipes (starting with _) and recipes with @ prefix
242
+ pattern = r"^([a-zA-Z][a-zA-Z0-9_-]*)\s*(?:[^:]*)?:"
243
+ recipes = re.findall(pattern, content, re.MULTILINE)
244
+
245
+ # Convert list to dict for _build_buttons compatibility
246
+ recipes_dict = {r: True for r in recipes}
247
+ priority = ["build", "test", "run", "dev", "check", "lint", "fmt", "clean"]
248
+
249
+ return _build_buttons(recipes_dict, priority, "just")
250
+ except Exception:
251
+ return []
252
+
253
+
254
+ def _discover_taskfile_tasks(base: Path) -> list[dict]:
255
+ """Extract tasks from Taskfile.yml."""
256
+ taskfile = _find_file(base, ["Taskfile.yml", "Taskfile.yaml", "taskfile.yml", "taskfile.yaml"])
257
+ if not taskfile:
258
+ return []
259
+
260
+ try:
261
+ tasks = yaml.safe_load(taskfile.read_text(encoding="utf-8")).get("tasks", {})
262
+ priority = ["build", "test", "run", "dev", "lint", "fmt", "clean", "default"]
263
+
264
+ return _build_buttons(tasks, priority, "task")
265
+ except Exception:
266
+ return []
@@ -14,8 +14,6 @@ from porterminal.application.services import (
14
14
  from porterminal.config import find_config_file
15
15
  from porterminal.container import Container
16
16
  from porterminal.domain import (
17
- EnvironmentRules,
18
- EnvironmentSanitizer,
19
17
  PTYPort,
20
18
  SessionLimitChecker,
21
19
  ShellCommand,
@@ -29,7 +27,7 @@ from porterminal.infrastructure.repositories import InMemorySessionRepository, I
29
27
 
30
28
  def create_pty_factory(
31
29
  cwd: str | None = None,
32
- ) -> Callable[[ShellCommand, TerminalDimensions, dict[str, str], str | None], PTYPort]:
30
+ ) -> Callable[[ShellCommand, TerminalDimensions, str | None], PTYPort]:
33
31
  """Create a PTY factory function.
34
32
 
35
33
  This bridges the domain PTYPort interface with the existing
@@ -40,7 +38,6 @@ def create_pty_factory(
40
38
  def factory(
41
39
  shell: ShellCommand,
42
40
  dimensions: TerminalDimensions,
43
- environment: dict[str, str],
44
41
  working_directory: str | None = None,
45
42
  ) -> PTYPort:
46
43
  # Use provided cwd or factory default
@@ -60,6 +57,7 @@ def create_pty_factory(
60
57
  )
61
58
 
62
59
  # Create manager (which implements PTY operations)
60
+ # Environment sanitization is handled internally by SecurePTYManager
63
61
  manager = SecurePTYManager(
64
62
  backend=backend,
65
63
  shell_config=legacy_shell,
@@ -68,8 +66,6 @@ def create_pty_factory(
68
66
  cwd=effective_cwd,
69
67
  )
70
68
 
71
- # Spawn with environment (manager handles sanitization internally,
72
- # but we pass our sanitized env to be safe)
73
69
  manager.spawn()
74
70
 
75
71
  return PTYManagerAdapter(manager, dimensions)
@@ -173,7 +169,6 @@ def create_container(
173
169
  repository=session_repository,
174
170
  pty_factory=pty_factory,
175
171
  limit_checker=SessionLimitChecker(),
176
- environment_sanitizer=EnvironmentSanitizer(EnvironmentRules()),
177
172
  working_directory=cwd,
178
173
  )
179
174
 
porterminal/config.py CHANGED
@@ -7,6 +7,7 @@ from pathlib import Path
7
7
  import yaml
8
8
  from pydantic import BaseModel, Field, field_validator
9
9
 
10
+ from porterminal.domain.values import MAX_COLS, MAX_ROWS, MIN_COLS, MIN_ROWS
10
11
  from porterminal.infrastructure.config import ShellDetector
11
12
 
12
13
 
@@ -43,8 +44,8 @@ class TerminalConfig(BaseModel):
43
44
  """Terminal configuration."""
44
45
 
45
46
  default_shell: str = ""
46
- cols: int = Field(default=120, ge=40, le=500)
47
- rows: int = Field(default=30, ge=10, le=200)
47
+ cols: int = Field(default=120, ge=MIN_COLS, le=MAX_COLS)
48
+ rows: int = Field(default=30, ge=MIN_ROWS, le=MAX_ROWS)
48
49
  shells: list[ShellConfig] = Field(default_factory=list)
49
50
 
50
51
  def get_shell(self, shell_id: str) -> ShellConfig | None:
@@ -60,6 +61,7 @@ class ButtonConfig(BaseModel):
60
61
 
61
62
  label: str
62
63
  send: str | list[str | int] = "" # string or list of strings/ints (ints = wait ms)
64
+ row: int = Field(default=1, ge=1, le=10) # toolbar row (1-10)
63
65
 
64
66
 
65
67
  class CloudflareConfig(BaseModel):
@@ -1,6 +1,5 @@
1
1
  """Pure domain layer - no infrastructure dependencies."""
2
2
 
3
- # Value Objects
4
3
  # Entities
5
4
  from .entities import (
6
5
  CLEAR_SCREEN_SEQUENCE,
@@ -23,7 +22,6 @@ from .ports import (
23
22
  # Services
24
23
  from .services import (
25
24
  Clock,
26
- EnvironmentSanitizer,
27
25
  SessionLimitChecker,
28
26
  SessionLimitConfig,
29
27
  SessionLimitResult,
@@ -33,13 +31,10 @@ from .services import (
33
31
  TokenBucketRateLimiter,
34
32
  )
35
33
  from .values import (
36
- DEFAULT_BLOCKED_VARS,
37
- DEFAULT_SAFE_VARS,
38
34
  MAX_COLS,
39
35
  MAX_ROWS,
40
36
  MIN_COLS,
41
37
  MIN_ROWS,
42
- EnvironmentRules,
43
38
  RateLimitConfig,
44
39
  SessionId,
45
40
  ShellCommand,
@@ -60,9 +55,6 @@ __all__ = [
60
55
  "TabId",
61
56
  "ShellCommand",
62
57
  "RateLimitConfig",
63
- "EnvironmentRules",
64
- "DEFAULT_SAFE_VARS",
65
- "DEFAULT_BLOCKED_VARS",
66
58
  # Entities
67
59
  "Session",
68
60
  "MAX_SESSIONS_PER_USER",
@@ -75,7 +67,6 @@ __all__ = [
75
67
  # Services
76
68
  "TokenBucketRateLimiter",
77
69
  "Clock",
78
- "EnvironmentSanitizer",
79
70
  "SessionLimitChecker",
80
71
  "SessionLimitConfig",
81
72
  "SessionLimitResult",
@@ -9,6 +9,11 @@ OUTPUT_BUFFER_MAX_BYTES = 1_000_000 # 1MB
9
9
  # Terminal escape sequence for clear screen (ED2)
10
10
  CLEAR_SCREEN_SEQUENCE = b"\x1b[2J"
11
11
 
12
+ # Alternate screen buffer sequences (DEC Private Mode)
13
+ # Used by vim, htop, less, tmux, etc.
14
+ ALT_SCREEN_ENTER = (b"\x1b[?47h", b"\x1b[?1047h", b"\x1b[?1049h")
15
+ ALT_SCREEN_EXIT = (b"\x1b[?47l", b"\x1b[?1047l", b"\x1b[?1049l")
16
+
12
17
 
13
18
  @dataclass
14
19
  class OutputBuffer:
@@ -16,12 +21,21 @@ class OutputBuffer:
16
21
 
17
22
  Pure domain logic for buffering terminal output.
18
23
  No async, no WebSocket - just data management.
24
+
25
+ Handles alternate screen buffer (used by vim, htop, less, etc.):
26
+ - On alt-screen enter: snapshots normal buffer, clears for alt content
27
+ - On alt-screen exit: restores normal buffer, discards alt content
19
28
  """
20
29
 
21
30
  max_bytes: int = OUTPUT_BUFFER_MAX_BYTES
22
31
  _buffer: deque[bytes] = field(default_factory=deque)
23
32
  _size: int = 0
24
33
 
34
+ # Alt-screen state
35
+ _in_alt_screen: bool = False
36
+ _normal_snapshot: deque[bytes] | None = None
37
+ _normal_snapshot_size: int = 0
38
+
25
39
  @property
26
40
  def size(self) -> int:
27
41
  """Current buffer size in bytes."""
@@ -32,12 +46,53 @@ class OutputBuffer:
32
46
  """Check if buffer is empty."""
33
47
  return self._size == 0
34
48
 
49
+ @property
50
+ def in_alt_screen(self) -> bool:
51
+ """Check if currently in alternate screen mode."""
52
+ return self._in_alt_screen
53
+
54
+ def _enter_alt_screen(self) -> None:
55
+ """Handle alt-screen entry: snapshot normal buffer."""
56
+ if self._in_alt_screen:
57
+ return # Already in alt-screen, ignore nested
58
+ self._in_alt_screen = True
59
+ self._normal_snapshot = self._buffer.copy()
60
+ self._normal_snapshot_size = self._size
61
+ self._clear_buffer()
62
+
63
+ def _exit_alt_screen(self) -> None:
64
+ """Handle alt-screen exit: restore normal buffer."""
65
+ if not self._in_alt_screen:
66
+ return # Not in alt-screen, ignore
67
+ self._in_alt_screen = False
68
+ if self._normal_snapshot is not None:
69
+ self._buffer = self._normal_snapshot
70
+ self._size = self._normal_snapshot_size
71
+ self._normal_snapshot = None
72
+ self._normal_snapshot_size = 0
73
+
74
+ def _clear_buffer(self) -> None:
75
+ """Clear the buffer contents only."""
76
+ self._buffer.clear()
77
+ self._size = 0
78
+
35
79
  def add(self, data: bytes) -> None:
36
80
  """Add data to the buffer.
37
81
 
38
- Handles clear screen detection and size limits.
82
+ Handles alt-screen transitions, clear screen detection, and size limits.
39
83
  When clear screen is detected, only keep content AFTER the last clear sequence.
40
84
  """
85
+ # Check alt-screen transitions FIRST
86
+ for pattern in ALT_SCREEN_EXIT:
87
+ if pattern in data:
88
+ self._exit_alt_screen()
89
+ break
90
+ else:
91
+ for pattern in ALT_SCREEN_ENTER:
92
+ if pattern in data:
93
+ self._enter_alt_screen()
94
+ return # Don't buffer alt-screen enter data
95
+
41
96
  # Check for clear screen sequence
42
97
  if CLEAR_SCREEN_SEQUENCE in data:
43
98
  # Clear old buffer
@@ -13,6 +13,15 @@ TAB_NAME_MIN_LENGTH = 1
13
13
  TAB_NAME_MAX_LENGTH = 50
14
14
 
15
15
 
16
+ def _validate_tab_name(name: str) -> None:
17
+ """Validate tab name length."""
18
+ if not (TAB_NAME_MIN_LENGTH <= len(name) <= TAB_NAME_MAX_LENGTH):
19
+ raise ValueError(
20
+ f"Tab name must be {TAB_NAME_MIN_LENGTH}-{TAB_NAME_MAX_LENGTH} "
21
+ f"characters, got {len(name)}"
22
+ )
23
+
24
+
16
25
  @dataclass
17
26
  class Tab:
18
27
  """Terminal tab entity.
@@ -35,11 +44,7 @@ class Tab:
35
44
  last_accessed: datetime
36
45
 
37
46
  def __post_init__(self) -> None:
38
- if not (TAB_NAME_MIN_LENGTH <= len(self.name) <= TAB_NAME_MAX_LENGTH):
39
- raise ValueError(
40
- f"Tab name must be {TAB_NAME_MIN_LENGTH}-{TAB_NAME_MAX_LENGTH} "
41
- f"characters, got {len(self.name)}"
42
- )
47
+ _validate_tab_name(self.name)
43
48
 
44
49
  @property
45
50
  def tab_id(self) -> str:
@@ -52,11 +57,7 @@ class Tab:
52
57
 
53
58
  def rename(self, new_name: str) -> None:
54
59
  """Rename the tab with validation."""
55
- if not (TAB_NAME_MIN_LENGTH <= len(new_name) <= TAB_NAME_MAX_LENGTH):
56
- raise ValueError(
57
- f"Tab name must be {TAB_NAME_MIN_LENGTH}-{TAB_NAME_MAX_LENGTH} "
58
- f"characters, got {len(new_name)}"
59
- )
60
+ _validate_tab_name(new_name)
60
61
  self.name = new_name
61
62
 
62
63
  def to_dict(self) -> dict:
@@ -1,6 +1,5 @@
1
1
  """Domain services - pure business logic operations."""
2
2
 
3
- from .environment_sanitizer import EnvironmentSanitizer
4
3
  from .rate_limiter import Clock, TokenBucketRateLimiter
5
4
  from .session_limits import SessionLimitChecker, SessionLimitConfig, SessionLimitResult
6
5
  from .tab_limits import TabLimitChecker, TabLimitConfig, TabLimitResult
@@ -8,7 +7,6 @@ from .tab_limits import TabLimitChecker, TabLimitConfig, TabLimitResult
8
7
  __all__ = [
9
8
  "TokenBucketRateLimiter",
10
9
  "Clock",
11
- "EnvironmentSanitizer",
12
10
  "SessionLimitChecker",
13
11
  "SessionLimitConfig",
14
12
  "SessionLimitResult",
@@ -1,6 +1,5 @@
1
1
  """Domain value objects - immutable data structures."""
2
2
 
3
- from .environment_rules import DEFAULT_BLOCKED_VARS, DEFAULT_SAFE_VARS, EnvironmentRules
4
3
  from .rate_limit_config import RateLimitConfig
5
4
  from .session_id import SessionId
6
5
  from .shell_command import ShellCommand
@@ -19,7 +18,4 @@ __all__ = [
19
18
  "TabId",
20
19
  "ShellCommand",
21
20
  "RateLimitConfig",
22
- "EnvironmentRules",
23
- "DEFAULT_SAFE_VARS",
24
- "DEFAULT_BLOCKED_VARS",
25
21
  ]
@@ -22,12 +22,15 @@ DEFAULT_SAFE_VARS: frozenset[str] = frozenset(
22
22
  "HOMEPATH",
23
23
  "LOCALAPPDATA",
24
24
  "APPDATA",
25
+ "PROGRAMDATA",
25
26
  "PROGRAMFILES",
26
27
  "PROGRAMFILES(X86)",
27
28
  "COMMONPROGRAMFILES",
28
29
  # System info
29
30
  "COMPUTERNAME",
30
31
  "USERNAME",
32
+ "USER",
33
+ "LOGNAME",
31
34
  "USERDOMAIN",
32
35
  "OS",
33
36
  "PROCESSOR_ARCHITECTURE",
@@ -2,9 +2,9 @@
2
2
 
3
3
  from dataclasses import dataclass
4
4
 
5
- # Default business rules
6
- DEFAULT_RATE = 100.0 # tokens per second
7
- DEFAULT_BURST = 500
5
+ # Defaults: 1KB/s sustained, 16KB burst (allows reasonable paste operations)
6
+ DEFAULT_RATE = 1000.0
7
+ DEFAULT_BURST = 16384
8
8
 
9
9
 
10
10
  @dataclass(frozen=True, slots=True)