ptn 0.2.5__py3-none-any.whl → 0.3.2__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.
@@ -1,11 +1,19 @@
1
1
  """Shell detection for available shells on the system."""
2
2
 
3
+ import json
4
+ import logging
5
+ import os
6
+ import re
7
+ import shlex
3
8
  import shutil
9
+ import subprocess
4
10
  import sys
5
11
  from pathlib import Path
6
12
 
7
13
  from porterminal.domain import ShellCommand
8
14
 
15
+ logger = logging.getLogger(__name__)
16
+
9
17
 
10
18
  class ShellDetector:
11
19
  """Detect available shells on the current platform."""
@@ -48,13 +56,20 @@ class ShellDetector:
48
56
  List of (name, id, command, args) tuples.
49
57
  """
50
58
  if sys.platform == "win32":
51
- return [
59
+ # Get Windows Terminal profiles first, then hardcoded defaults
60
+ wt_profiles = self._get_windows_terminal_profiles()
61
+ hardcoded = [
52
62
  ("PS 7", "pwsh", "pwsh.exe", ["-NoLogo"]),
53
63
  ("PS", "powershell", "powershell.exe", ["-NoLogo"]),
54
64
  ("CMD", "cmd", "cmd.exe", []),
55
65
  ("WSL", "wsl", "wsl.exe", []),
56
66
  ("Git Bash", "gitbash", r"C:\Program Files\Git\bin\bash.exe", ["--login"]),
57
67
  ]
68
+ # Merge WT profiles with hardcoded (dedupe by command)
69
+ merged = self._merge_candidates(wt_profiles, hardcoded)
70
+ # Add VS dev shells (no deduplication - they're unique due to args)
71
+ vs_shells = self._get_visual_studio_shells()
72
+ return merged + vs_shells
58
73
  return [
59
74
  ("Bash", "bash", "bash", ["--login"]),
60
75
  ("Zsh", "zsh", "zsh", ["--login"]),
@@ -62,6 +77,298 @@ class ShellDetector:
62
77
  ("Sh", "sh", "sh", []),
63
78
  ]
64
79
 
80
+ def _get_windows_terminal_profiles(self) -> list[tuple[str, str, str, list[str]]]:
81
+ """Read shell profiles from Windows Terminal settings.json.
82
+
83
+ Returns:
84
+ List of (name, id, command, args) tuples from Windows Terminal.
85
+ """
86
+ settings_path = Path(
87
+ os.environ.get("LOCALAPPDATA", ""),
88
+ "Packages",
89
+ "Microsoft.WindowsTerminal_8wekyb3d8bbwe",
90
+ "LocalState",
91
+ "settings.json",
92
+ )
93
+
94
+ if not settings_path.exists():
95
+ return []
96
+
97
+ try:
98
+ # Read and parse JSON (WT uses JSON with comments, strip them)
99
+ content = settings_path.read_text(encoding="utf-8")
100
+ content = self._strip_json_comments(content)
101
+ data = json.loads(content)
102
+ except (OSError, json.JSONDecodeError) as e:
103
+ logger.warning("Failed to read Windows Terminal settings: %s", e)
104
+ return []
105
+
106
+ profiles = []
107
+ profile_list = data.get("profiles", {}).get("list", [])
108
+
109
+ for profile in profile_list:
110
+ name = profile.get("name", "")
111
+ commandline = profile.get("commandline", "")
112
+
113
+ if not name or not commandline:
114
+ continue
115
+
116
+ # Parse commandline into command and args
117
+ command, args = self._parse_commandline(commandline)
118
+ if not command:
119
+ continue
120
+
121
+ # Expand environment variables in command path
122
+ command = os.path.expandvars(command)
123
+
124
+ # Create a slug ID from the name
125
+ shell_id = self._slugify(name)
126
+
127
+ # Shorten display name
128
+ short_name = self._abbreviate_name(name)
129
+
130
+ profiles.append((short_name, shell_id, command, args))
131
+
132
+ return profiles
133
+
134
+ def _get_visual_studio_shells(self) -> list[tuple[str, str, str, list[str]]]:
135
+ """Detect Visual Studio Developer Command Prompts and PowerShells.
136
+
137
+ Returns:
138
+ List of (name, id, command, args) tuples for VS dev shells.
139
+ """
140
+ vswhere = Path(
141
+ os.environ.get("ProgramFiles(x86)", ""),
142
+ "Microsoft Visual Studio",
143
+ "Installer",
144
+ "vswhere.exe",
145
+ )
146
+
147
+ if not vswhere.exists():
148
+ return []
149
+
150
+ try:
151
+ result = subprocess.run(
152
+ [
153
+ str(vswhere),
154
+ "-all",
155
+ "-prerelease",
156
+ "-property",
157
+ "installationPath",
158
+ ],
159
+ capture_output=True,
160
+ text=True,
161
+ timeout=5,
162
+ )
163
+ vs_paths = [p.strip() for p in result.stdout.strip().split("\n") if p.strip()]
164
+ except (subprocess.TimeoutExpired, OSError) as e:
165
+ logger.warning("Failed to run vswhere: %s", e)
166
+ return []
167
+
168
+ shells = []
169
+ for vs_path in vs_paths:
170
+ vs_path = Path(vs_path)
171
+ # Extract VS version and edition from path
172
+ # e.g., "C:\Program Files\Microsoft Visual Studio\2022\Community"
173
+ edition = vs_path.name # Community, Professional, Enterprise
174
+ year = vs_path.parent.name # 2022, 2019, etc.
175
+
176
+ # Developer Command Prompt (CMD)
177
+ vsdevcmd = vs_path / "Common7" / "Tools" / "VsDevCmd.bat"
178
+ if vsdevcmd.exists():
179
+ name = f"Dev CMD {year}"
180
+ shell_id = f"devcmd-{year}-{edition.lower()}"
181
+ # cmd.exe /k "path\to\VsDevCmd.bat"
182
+ shells.append(
183
+ (
184
+ name,
185
+ shell_id,
186
+ "cmd.exe",
187
+ ["/k", str(vsdevcmd)],
188
+ )
189
+ )
190
+
191
+ # Developer PowerShell
192
+ launch_ps = vs_path / "Common7" / "Tools" / "Launch-VsDevShell.ps1"
193
+ if launch_ps.exists():
194
+ name = f"Dev PS {year}"
195
+ shell_id = f"devps-{year}-{edition.lower()}"
196
+ # powershell.exe -NoExit -Command "& 'path\to\Launch-VsDevShell.ps1'"
197
+ shells.append(
198
+ (
199
+ name,
200
+ shell_id,
201
+ "powershell.exe",
202
+ ["-NoExit", "-Command", f"& '{launch_ps}'"],
203
+ )
204
+ )
205
+
206
+ return shells
207
+
208
+ def _strip_json_comments(self, content: str) -> str:
209
+ """Strip comments from JSON content while preserving strings.
210
+
211
+ Handles:
212
+ - Single-line comments: // comment
213
+ - Multi-line comments: /* comment */
214
+ - Preserves // inside quoted strings (e.g., URLs)
215
+
216
+ Args:
217
+ content: JSON content with possible comments
218
+
219
+ Returns:
220
+ JSON content without comments
221
+ """
222
+ result = []
223
+ i = 0
224
+ in_string = False
225
+ escape_next = False
226
+
227
+ while i < len(content):
228
+ char = content[i]
229
+
230
+ if escape_next:
231
+ result.append(char)
232
+ escape_next = False
233
+ i += 1
234
+ continue
235
+
236
+ if char == "\\" and in_string:
237
+ result.append(char)
238
+ escape_next = True
239
+ i += 1
240
+ continue
241
+
242
+ if char == '"' and not escape_next:
243
+ in_string = not in_string
244
+ result.append(char)
245
+ i += 1
246
+ continue
247
+
248
+ if not in_string:
249
+ # Check for single-line comment
250
+ if content[i : i + 2] == "//":
251
+ # Skip to end of line
252
+ while i < len(content) and content[i] != "\n":
253
+ i += 1
254
+ continue
255
+ # Check for multi-line comment
256
+ if content[i : i + 2] == "/*":
257
+ i += 2
258
+ while i < len(content) - 1 and content[i : i + 2] != "*/":
259
+ i += 1
260
+ i += 2 # Skip */
261
+ continue
262
+
263
+ result.append(char)
264
+ i += 1
265
+
266
+ return "".join(result)
267
+
268
+ def _parse_commandline(self, commandline: str) -> tuple[str, list[str]]:
269
+ """Parse a commandline string into command and args.
270
+
271
+ Args:
272
+ commandline: The command line string (e.g., 'cmd.exe /k "vcvars64.bat"')
273
+
274
+ Returns:
275
+ Tuple of (command, args_list)
276
+ """
277
+ try:
278
+ # Use shlex to properly handle quoted arguments
279
+ parts = shlex.split(commandline, posix=False)
280
+ if not parts:
281
+ return "", []
282
+ return parts[0], parts[1:]
283
+ except ValueError:
284
+ # Fallback: simple split on first space
285
+ parts = commandline.split(None, 1)
286
+ if not parts:
287
+ return "", []
288
+ return parts[0], parts[1:] if len(parts) > 1 else []
289
+
290
+ def _abbreviate_name(self, name: str) -> str:
291
+ """Abbreviate a shell name for display.
292
+
293
+ Args:
294
+ name: Full shell name (e.g., "Windows PowerShell")
295
+
296
+ Returns:
297
+ Abbreviated name (e.g., "WinPS")
298
+ """
299
+ # Common abbreviations
300
+ abbrevs = {
301
+ "Windows PowerShell": "WinPS",
302
+ "Command Prompt": "CMD",
303
+ "PowerShell": "PS",
304
+ "Developer Command Prompt": "DevCMD",
305
+ "Developer PowerShell": "DevPS",
306
+ "Azure Cloud Shell": "Azure",
307
+ "Git Bash": "GitBash",
308
+ }
309
+
310
+ # Check for exact or prefix match
311
+ for full, short in abbrevs.items():
312
+ if name == full or name.startswith(full):
313
+ # Append suffix if there's more (e.g., "for VS 2022")
314
+ suffix = name[len(full) :].strip()
315
+ if suffix:
316
+ # Extract version/year if present
317
+ parts = suffix.split()
318
+ for part in parts:
319
+ if part.isdigit() and len(part) == 4: # Year like 2022
320
+ return f"{short} {part}"
321
+ return short
322
+
323
+ # Fallback: first 8 chars if name is long
324
+ if len(name) > 10:
325
+ return name[:8].strip()
326
+ return name
327
+
328
+ def _slugify(self, name: str) -> str:
329
+ """Convert a profile name to a slug ID.
330
+
331
+ Args:
332
+ name: Profile name (e.g., "Developer PowerShell for VS 2022")
333
+
334
+ Returns:
335
+ Slug ID (e.g., "developer-powershell-for-vs-2022")
336
+ """
337
+ # Lowercase, replace non-alphanumeric with hyphens, collapse multiple hyphens
338
+ slug = re.sub(r"[^a-z0-9]+", "-", name.lower())
339
+ return slug.strip("-")
340
+
341
+ def _merge_candidates(
342
+ self,
343
+ primary: list[tuple[str, str, str, list[str]]],
344
+ secondary: list[tuple[str, str, str, list[str]]],
345
+ ) -> list[tuple[str, str, str, list[str]]]:
346
+ """Merge two candidate lists, deduplicating by command executable.
347
+
348
+ Args:
349
+ primary: First list (takes priority)
350
+ secondary: Second list (skipped if command already in primary)
351
+
352
+ Returns:
353
+ Merged and deduplicated list
354
+ """
355
+ result = list(primary)
356
+ seen_commands = set()
357
+
358
+ # Track commands from primary (normalize to lowercase basename)
359
+ for _, _, command, _ in primary:
360
+ cmd_name = Path(command).name.lower()
361
+ seen_commands.add(cmd_name)
362
+
363
+ # Add secondary items if command not already seen
364
+ for item in secondary:
365
+ cmd_name = Path(item[2]).name.lower()
366
+ if cmd_name not in seen_commands:
367
+ result.append(item)
368
+ seen_commands.add(cmd_name)
369
+
370
+ return result
371
+
65
372
  def _get_windows_default(self) -> str:
66
373
  """Get default shell ID for Windows."""
67
374
  if shutil.which("pwsh.exe"):
@@ -72,14 +379,47 @@ class ShellDetector:
72
379
 
73
380
  def _get_macos_default(self) -> str:
74
381
  """Get default shell ID for macOS."""
382
+ # Check user's configured shell from $SHELL
383
+ user_shell = self._get_user_shell_id()
384
+ if user_shell:
385
+ return user_shell
386
+ # Fallback to zsh (macOS default since Catalina)
75
387
  if shutil.which("zsh"):
76
388
  return "zsh"
77
389
  return "bash"
78
390
 
79
391
  def _get_linux_default(self) -> str:
80
392
  """Get default shell ID for Linux."""
393
+ # Check user's configured shell from $SHELL
394
+ user_shell = self._get_user_shell_id()
395
+ if user_shell:
396
+ return user_shell
397
+ # Fallback
81
398
  if shutil.which("bash"):
82
399
  return "bash"
83
400
  if shutil.which("zsh"):
84
401
  return "zsh"
85
402
  return "sh"
403
+
404
+ def _get_user_shell_id(self) -> str | None:
405
+ """Get shell ID from user's $SHELL environment variable.
406
+
407
+ Returns:
408
+ Shell ID if $SHELL is set and matches a known shell, None otherwise.
409
+ """
410
+ shell_path = os.environ.get("SHELL", "")
411
+ if not shell_path:
412
+ return None
413
+
414
+ # Extract shell name from path (e.g., /usr/bin/fish -> fish)
415
+ shell_name = Path(shell_path).name.lower()
416
+
417
+ # Map common shell names to IDs
418
+ shell_map = {
419
+ "bash": "bash",
420
+ "zsh": "zsh",
421
+ "fish": "fish",
422
+ "sh": "sh",
423
+ }
424
+
425
+ return shell_map.get(shell_name)
@@ -76,15 +76,22 @@ def start_server(host: str, port: int, *, verbose: bool = False) -> subprocess.P
76
76
  "--no-access-log", # Disable access logging
77
77
  ]
78
78
 
79
+ # On Windows, use CREATE_NEW_PROCESS_GROUP to prevent Ctrl+C from propagating
80
+ # to the child process - we handle cleanup ourselves
81
+ kwargs = {}
82
+ if sys.platform == "win32":
83
+ kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
84
+
79
85
  if verbose:
80
86
  # Let output go directly to console
81
- process = subprocess.Popen(cmd)
87
+ process = subprocess.Popen(cmd, **kwargs)
82
88
  else:
83
89
  process = subprocess.Popen(
84
90
  cmd,
85
91
  stdout=subprocess.PIPE,
86
92
  stderr=subprocess.STDOUT,
87
93
  text=True,
94
+ **kwargs,
88
95
  )
89
96
 
90
97
  return process
@@ -120,6 +127,11 @@ def start_cloudflared(port: int) -> tuple[subprocess.Popen, str | None]:
120
127
  # Point to a non-existent config to force quick tunnel mode
121
128
  env["TUNNEL_CONFIG"] = os.devnull
122
129
 
130
+ # On Windows, use CREATE_NEW_PROCESS_GROUP to prevent Ctrl+C from propagating
131
+ kwargs = {}
132
+ if sys.platform == "win32":
133
+ kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
134
+
123
135
  process = subprocess.Popen(
124
136
  cmd,
125
137
  stdout=subprocess.PIPE,
@@ -127,6 +139,7 @@ def start_cloudflared(port: int) -> tuple[subprocess.Popen, str | None]:
127
139
  text=True,
128
140
  bufsize=1,
129
141
  env=env,
142
+ **kwargs,
130
143
  )
131
144
 
132
145
  # Parse URL from cloudflared output (flexible pattern for different Cloudflare domains)
@@ -151,7 +164,7 @@ def start_cloudflared(port: int) -> tuple[subprocess.Popen, str | None]:
151
164
 
152
165
 
153
166
  def drain_process_output(process: subprocess.Popen) -> None:
154
- """Drain process output silently (only print errors).
167
+ """Drain process output silently (only print errors and security warnings).
155
168
 
156
169
  Args:
157
170
  process: Subprocess to drain output from.
@@ -161,8 +174,20 @@ def drain_process_output(process: subprocess.Popen) -> None:
161
174
  if not line:
162
175
  break
163
176
  line = line.strip()
177
+ if not line:
178
+ continue
179
+ # Always print security warnings and related messages
180
+ if any(
181
+ kw in line.lower()
182
+ for kw in (
183
+ "security warning",
184
+ "authentication attempts",
185
+ "url may have been leaked",
186
+ )
187
+ ):
188
+ console.print(f"[bold red]{line}[/bold red]")
164
189
  # Print errors, but ignore harmless ICMP/ping warnings
165
- if line and "error" in line.lower() and not _is_icmp_warning(line):
190
+ elif "error" in line.lower() and not _is_icmp_warning(line):
166
191
  console.print(f"[red]{line}[/red]")
167
192
  except (OSError, ValueError):
168
193
  pass
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Copyright (c) 2014 The xterm.js authors. All rights reserved.
3
+ * Copyright (c) 2012-2013, Christopher Jeffrey (MIT License)
4
+ * https://github.com/chjj/term.js
5
+ * @license MIT
6
+ *
7
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
8
+ * of this software and associated documentation files (the "Software"), to deal
9
+ * in the Software without restriction, including without limitation the rights
10
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11
+ * copies of the Software, and to permit persons to whom the Software is
12
+ * furnished to do so, subject to the following conditions:
13
+ *
14
+ * The above copyright notice and this permission notice shall be included in
15
+ * all copies or substantial portions of the Software.
16
+ *
17
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
23
+ * THE SOFTWARE.
24
+ *
25
+ * Originally forked from (with the author's permission):
26
+ * Fabrice Bellard's javascript vt100 for jslinux:
27
+ * http://bellard.org/jslinux/
28
+ * Copyright (c) 2011 Fabrice Bellard
29
+ * The original design remains. The terminal itself
30
+ * has been extended to include xterm CSI codes, among
31
+ * other features.
32
+ */.xterm{cursor:text;position:relative;user-select:none;-ms-user-select:none;-webkit-user-select:none}.xterm.focus,.xterm:focus{outline:none}.xterm .xterm-helpers{position:absolute;top:0;z-index:5}.xterm .xterm-helper-textarea{padding:0;border:0;margin:0;position:absolute;opacity:0;left:-9999em;top:0;width:0;height:0;z-index:-5;white-space:nowrap;overflow:hidden;resize:none}.xterm .composition-view{background:#000;color:#fff;display:none;position:absolute;white-space:nowrap;z-index:1}.xterm .composition-view.active{display:block}.xterm .xterm-viewport{background-color:#000;overflow-y:scroll;cursor:default;position:absolute;right:0;left:0;top:0;bottom:0}.xterm .xterm-screen{position:relative}.xterm .xterm-screen canvas{position:absolute;left:0;top:0}.xterm .xterm-scroll-area{visibility:hidden}.xterm-char-measure-element{display:inline-block;visibility:hidden;position:absolute;top:0;left:-9999em;line-height:normal}.xterm.enable-mouse-events{cursor:default}.xterm.xterm-cursor-pointer,.xterm .xterm-cursor-pointer{cursor:pointer}.xterm.column-select.focus{cursor:crosshair}.xterm .xterm-accessibility:not(.debug),.xterm .xterm-message{position:absolute;left:0;top:0;bottom:0;right:0;z-index:10;color:transparent;pointer-events:none}.xterm .xterm-accessibility-tree:not(.debug) *::selection{color:transparent}.xterm .xterm-accessibility-tree{-webkit-user-select:text;user-select:text;white-space:pre}.xterm .live-region{position:absolute;left:-9999px;width:1px;height:1px;overflow:hidden}.xterm-dim{opacity:1!important}.xterm-underline-1{text-decoration:underline}.xterm-underline-2{text-decoration:double underline}.xterm-underline-3{text-decoration:wavy underline}.xterm-underline-4{text-decoration:dotted underline}.xterm-underline-5{text-decoration:dashed underline}.xterm-overline{text-decoration:overline}.xterm-overline.xterm-underline-1{text-decoration:overline underline}.xterm-overline.xterm-underline-2{text-decoration:overline double underline}.xterm-overline.xterm-underline-3{text-decoration:overline wavy underline}.xterm-overline.xterm-underline-4{text-decoration:overline dotted underline}.xterm-overline.xterm-underline-5{text-decoration:overline dashed underline}.xterm-strikethrough{text-decoration:line-through}.xterm-screen .xterm-decoration-container .xterm-decoration{z-index:6;position:absolute}.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer{z-index:7}.xterm-decoration-overview-ruler{z-index:8;position:absolute;top:0;right:0;pointer-events:none}.xterm-decoration-top{z-index:2;position:relative}*{box-sizing:border-box;-webkit-tap-highlight-color:transparent}:root{--row-height: 30px;--content-padding: 12px;--scrollbar-width: 8px;--transition-fast: .15s ease;--radius-sm: 4px;--radius-md: 6px;--radius-lg: 8px;--radius-xl: 12px;--bg-primary: #1e1e1e;--bg-elevated: #252525;--bg-surface: #2d2d2d;--bg-hover: #3a3a3a;--bg-gradient-top: #232323;--bg-gradient-bottom: #1a1a1a;--text-primary: #cccccc;--text-high: rgba(255, 255, 255, .9);--text-white: #fff;--text-secondary: rgba(255, 255, 255, .7);--text-muted: rgba(255, 255, 255, .5);--text-disabled: rgba(255, 255, 255, .4);--border-subtle: rgba(255, 255, 255, .05);--border-light: rgba(255, 255, 255, .1);--border-medium: rgba(255, 255, 255, .15);--border-active: rgba(255, 255, 255, .2);--color-success: rgba(100, 220, 100, .8);--color-success-text: rgba(130, 230, 150, 1);--color-success-bg: rgba(100, 200, 120, .15);--color-success-border: rgba(100, 200, 120, .3);--bg-success-gradient-top: #2a3a2a;--bg-success-gradient-bottom: #1f2f1f;--color-danger: rgba(255, 100, 100, .8);--color-danger-muted: rgba(255, 120, 120, .7);--color-danger-text: rgba(255, 150, 150, 1);--color-danger-bg: rgba(255, 80, 80, .15);--color-danger-border: rgba(255, 120, 120, .3);--bg-danger-gradient-top: #3a2a2a;--bg-danger-gradient-bottom: #2f1f1f;--color-accent: rgba(80, 160, 255, .8);--color-accent-text: rgba(150, 200, 255, 1);--color-accent-bg: rgba(80, 160, 255, .3);--color-accent-strong: rgba(80, 160, 255, .5);--color-locked: rgba(255, 160, 60, .8);--color-locked-bg: rgba(255, 160, 60, .5);--glow-locked: 0 0 8px rgba(255, 160, 60, .5);--color-tmux: rgba(0, 180, 0, .8);--color-tmux-text: rgba(100, 220, 100, 1);--color-tmux-bg: rgba(0, 180, 0, .15);--hover-overlay: rgba(255, 255, 255, .05);--active-overlay: rgba(255, 255, 255, .08);--scrollbar-thumb: rgba(255, 255, 255, .15);--scrollbar-hover: rgba(255, 255, 255, .25);--cursor-color: #aeafad;--selection-bg: rgba(38, 79, 120, .5);--shadow-elevated: 0 4px 16px rgba(0, 0, 0, .5);--glow-success: 0 0 6px rgba(100, 220, 100, .4);--glow-danger: 0 0 6px rgba(255, 100, 100, .4);--glow-accent: 0 0 8px rgba(80, 160, 255, .4);--overlay-bg: rgba(0, 0, 0, .9)}.tool-btn,.tab-btn{-webkit-touch-callout:none}html,body{margin:0;padding:0;height:100%;overflow:hidden;background:var(--bg-primary);color:var(--text-primary);font-family:-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;font-size:13px;position:fixed;width:100%;touch-action:none;overscroll-behavior:none}#app{display:flex;flex-direction:column;height:100%;height:100dvh;height:100svh;padding-top:env(safe-area-inset-top,0px);padding-bottom:env(safe-area-inset-bottom,0px);overflow:hidden;overscroll-behavior:none}#tab-bar{display:flex;align-items:center;height:var(--row-height);min-height:var(--row-height);background:linear-gradient(to bottom,var(--bg-gradient-top),var(--bg-gradient-bottom));border-bottom:1px solid var(--border-subtle);overflow-x:auto;overflow-y:hidden;scrollbar-width:none;-ms-overflow-style:none}#tab-bar::-webkit-scrollbar{display:none}.tab-btn{display:flex;align-items:center;gap:2px;height:var(--row-height);padding:0 8px;background:transparent;border:none;border-right:1px solid var(--border-subtle);color:var(--text-muted);font-size:12px;cursor:pointer;white-space:nowrap;flex-shrink:0;transition:all var(--transition-fast)}.tab-btn:active{background:var(--hover-overlay)}.tab-btn.active{background:var(--active-overlay);color:var(--text-high)}.tab-btn.tab-add{color:var(--text-disabled);font-size:18px;padding:0 10px;border-right:none}.tab-btn.tab-add:active{color:var(--text-high)}.tab-label{max-width:100px;overflow:hidden;text-overflow:ellipsis}.tab-close{display:flex;align-items:center;justify-content:center;width:16px;height:16px;margin-left:2px;border-radius:var(--radius-sm);font-size:14px;color:var(--text-disabled);transition:all var(--transition-fast);position:relative;overflow:hidden}.tab-close:before{content:"";position:absolute;top:0;left:0;width:100%;height:100%;background:var(--color-danger-bg);transform:scaleX(0);transform-origin:left;transition:transform .4s ease-out;z-index:-1}.tab-close.holding:before{transform:scaleX(1)}.tab-close.holding{color:var(--color-danger-text)}.tab-close.ready{background:var(--color-danger-bg);color:var(--color-danger-text);animation:pulseReady .3s ease}@keyframes pulseReady{0%,to{transform:scale(1)}50%{transform:scale(1.1)}}#shell-selector{margin-left:auto;display:flex;align-items:center;padding:0 var(--content-padding);height:var(--row-height);border-left:1px solid var(--border-subtle)}#shell-select{background:transparent;border:none;color:var(--text-secondary);font-size:11px;padding:4px 8px;cursor:pointer;-webkit-appearance:none;-moz-appearance:none;appearance:none}#connection-dot{width:6px;height:6px;border-radius:50%;background:var(--color-danger);margin-left:var(--content-padding);margin-right:8px;box-shadow:var(--glow-danger);flex-shrink:0}#connection-dot.connected{background:var(--color-success);box-shadow:var(--glow-success)}#btn-info,#btn-textview,#btn-shutdown{background:transparent;border:none;color:var(--text-disabled);font-size:14px;padding:4px;margin-left:4px;cursor:pointer;transition:color var(--transition-fast)}#btn-textview{padding:4px 0;margin-left:2px;margin-right:4px;font-size:11px;font-weight:500}#btn-info:active,#btn-textview:active{color:var(--color-accent)}#btn-shutdown{color:var(--color-danger-muted)}#btn-shutdown:active{color:var(--color-danger-text)}#terminal-container{flex:1;overflow:hidden;background:var(--bg-primary);padding:0 0 0 var(--content-padding);-webkit-user-select:none;user-select:none;-webkit-touch-callout:none;touch-action:none;overscroll-behavior:contain;contain:strict;isolation:isolate}#terminal{height:100%!important;contain:layout paint}.terminal-instance{height:100%;width:100%;contain:layout paint}.xterm-screen canvas,.xterm canvas{transform:translateZ(0);backface-visibility:hidden}#terminal .xterm-viewport{background:var(--bg-primary)!important;overflow-y:overlay!important;touch-action:none;overscroll-behavior:contain;contain:paint}#terminal .xterm-viewport::-webkit-scrollbar{width:var(--scrollbar-width)}#terminal .xterm-viewport::-webkit-scrollbar-track{background:transparent}#terminal .xterm-viewport::-webkit-scrollbar-thumb{background:var(--scrollbar-thumb);border-radius:var(--radius-sm)}#terminal .xterm-viewport::-webkit-scrollbar-thumb:hover{background:var(--scrollbar-hover)}#terminal .xterm-screen{-webkit-user-select:none;user-select:none;-webkit-touch-callout:none}#terminal .xterm-helper-textarea,.xterm-helper-textarea{-webkit-text-security:none!important;font-size:16px!important;-webkit-user-select:text;user-select:text}.xterm-helper-textarea::-webkit-contacts-auto-fill-button,.xterm-helper-textarea::-webkit-credentials-auto-fill-button{visibility:hidden;display:none!important;pointer-events:none;position:absolute;right:0;width:0;height:0}#toolbar{display:flex;flex-direction:column;background:linear-gradient(to bottom,var(--bg-gradient-top),var(--bg-gradient-bottom));border-top:1px solid var(--border-subtle);padding-bottom:env(safe-area-inset-bottom,0px)}.toolbar-row{display:flex;align-items:center;justify-content:center;height:var(--row-height);min-height:var(--row-height);gap:0;overflow-x:auto;overflow-y:hidden;scrollbar-width:none;-ms-overflow-style:none}.toolbar-row:first-child{border-bottom:1px solid var(--border-subtle)}.toolbar-row::-webkit-scrollbar{display:none}.tool-btn{display:flex;align-items:center;justify-content:center;height:var(--row-height);padding:0 10px;background:transparent;border:none;border-right:1px solid var(--border-subtle);border-radius:0;color:var(--text-muted);font-size:12px;cursor:pointer;transition:all var(--transition-fast);-webkit-touch-callout:none;flex-shrink:0}.tool-btn:active{background:var(--hover-overlay);color:var(--text-high)}.tool-btn.arrow{padding:0 10px}.tool-btn.danger{color:var(--color-danger-muted)}.tool-btn.danger:active{background:var(--color-danger-bg);color:var(--color-danger-text)}.tool-btn.tmux{color:var(--color-tmux)}.tool-btn.tmux:active{background:var(--color-tmux-bg);color:var(--color-tmux-text)}.tool-btn.modifier{padding:0 10px}.tool-btn.icon{font-size:18px}.tool-btn.enter{color:var(--color-success)}.tool-btn.enter:active{background:var(--color-success-bg);color:var(--color-success-text)}.tool-btn.sticky{background:var(--color-accent-bg);color:var(--color-accent-text)}.tool-btn.locked{background:var(--color-locked-bg);color:var(--text-white);box-shadow:var(--glow-locked)}#disconnect-overlay{position:fixed;top:0;left:0;right:0;bottom:0;background:var(--overlay-bg);z-index:1000;display:flex;align-items:center;justify-content:center}#disconnect-content{text-align:center}#disconnect-icon{font-size:48px;color:var(--color-danger);margin-bottom:16px}#disconnect-text{color:var(--text-secondary);font-size:18px;margin-bottom:24px}#disconnect-retry{padding:var(--content-padding) 32px;background:var(--border-light);border:1px solid var(--border-medium);border-radius:var(--radius-lg);color:var(--text-high);font-size:14px;cursor:pointer;transition:all var(--transition-fast)}#disconnect-retry:active{background:var(--border-active);color:var(--text-white)}#auth-overlay{position:fixed;top:0;left:0;right:0;bottom:0;background:var(--overlay-bg);z-index:1001;display:flex;align-items:center;justify-content:center;padding:20px}#auth-content{background:var(--bg-elevated);border:1px solid var(--border-light);border-radius:var(--radius-xl);max-width:320px;width:100%;box-shadow:var(--shadow-elevated);animation:helpAppear var(--transition-fast)}#auth-header{display:flex;align-items:center;justify-content:center;padding:var(--content-padding) 16px;border-bottom:1px solid var(--border-subtle);color:var(--text-high);font-size:14px;font-weight:500}#auth-body{padding:16px;display:flex;flex-direction:column;gap:12px}#auth-error{padding:8px 12px;background:var(--color-danger-bg);border:1px solid var(--color-danger-border);border-radius:var(--radius-md);color:var(--color-danger-text);font-size:12px;text-align:center}#auth-password{padding:10px 12px;background:var(--bg-surface);border:1px solid var(--border-medium);border-radius:var(--radius-md);color:var(--text-high);font-size:14px;outline:none;transition:border-color var(--transition-fast)}#auth-password:focus{border-color:var(--color-accent)}#auth-password::placeholder{color:var(--text-disabled)}#auth-submit{padding:var(--content-padding) 16px;background:var(--border-light);border:1px solid var(--border-medium);border-radius:var(--radius-lg);color:var(--text-high);font-size:14px;cursor:pointer;transition:all var(--transition-fast)}#auth-submit:active{background:var(--border-active);color:var(--text-white)}#auth-submit:disabled{opacity:.5;cursor:not-allowed}#copy-button{position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);padding:8px 20px;background:linear-gradient(to bottom,var(--bg-surface),var(--bg-elevated));border:1px solid var(--border-light);border-radius:var(--radius-md);color:var(--text-high);font-family:-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;font-size:13px;font-weight:500;cursor:pointer;z-index:10000;box-shadow:var(--shadow-elevated);display:none;-webkit-tap-highlight-color:transparent;-webkit-touch-callout:none;touch-action:manipulation;transition:all var(--transition-fast)}#copy-button:active{background:linear-gradient(to bottom,var(--bg-hover),var(--bg-surface));transform:translate(-50%,-50%) scale(.97)}#copy-button.visible{display:block;animation:copyButtonAppear .1s ease-out}#copy-button.success{background:linear-gradient(to bottom,var(--bg-success-gradient-top),var(--bg-success-gradient-bottom));border-color:var(--color-success-border);color:var(--color-success-text)}#copy-button.error{background:linear-gradient(to bottom,var(--bg-danger-gradient-top),var(--bg-danger-gradient-bottom));border-color:var(--color-danger-border);color:var(--color-danger-text)}@keyframes copyButtonAppear{0%{opacity:0;transform:translate(-50%,-50%) scale(.9)}to{opacity:1;transform:translate(-50%,-50%) scale(1)}}#help-overlay{position:fixed;top:0;left:0;right:0;bottom:0;background:var(--overlay-bg);z-index:1000;display:flex;align-items:center;justify-content:center;padding:20px}#help-content{background:var(--bg-elevated);border:1px solid var(--border-light);border-radius:var(--radius-xl);max-width:320px;width:100%;box-shadow:var(--shadow-elevated);animation:helpAppear var(--transition-fast)}@keyframes helpAppear{0%{opacity:0;transform:scale(.95)}to{opacity:1;transform:scale(1)}}#help-header{display:flex;align-items:center;justify-content:space-between;padding:var(--content-padding) 16px;border-bottom:1px solid var(--border-subtle);color:var(--text-high);font-size:14px;font-weight:500}#help-close{background:transparent;border:none;color:var(--text-muted);font-size:20px;cursor:pointer;padding:4px 8px;line-height:1}#help-close:active{color:var(--text-high)}#help-body{padding:var(--content-padding) 16px}.help-section{margin-bottom:var(--content-padding)}.help-section:last-child{margin-bottom:0}.help-title{color:var(--text-muted);font-size:10px;text-transform:uppercase;letter-spacing:.5px;margin-bottom:6px}.help-item{display:flex;align-items:center;gap:10px;padding:4px 0;color:var(--text-secondary);font-size:12px}.help-key{display:inline-block;min-width:80px;padding:2px 6px;background:var(--bg-surface);border:1px solid var(--border-subtle);border-radius:var(--radius-sm);color:var(--text-high);font-size:11px;text-align:center}#textview-overlay{position:fixed;top:0;left:0;right:0;bottom:0;background:var(--bg-primary);z-index:1000;display:flex;flex-direction:column;padding-top:env(safe-area-inset-top,0px);padding-bottom:env(safe-area-inset-bottom,0px)}#textview-content{display:flex;flex-direction:column;width:100%;height:100%;background:var(--bg-primary)}#textview-header{display:flex;align-items:center;height:var(--row-height);min-height:var(--row-height);background:linear-gradient(to bottom,var(--bg-gradient-top),var(--bg-gradient-bottom));border-bottom:1px solid var(--border-subtle);flex-shrink:0}#textview-title{padding:0 var(--content-padding);color:var(--text-muted);font-size:12px;border-right:1px solid var(--border-subtle)}.textview-zoom-btn{display:flex;align-items:center;justify-content:center;height:var(--row-height);padding:0 var(--content-padding);background:transparent;border:none;border-right:1px solid var(--border-subtle);color:var(--text-muted);font-size:14px;cursor:pointer;transition:all var(--transition-fast)}.textview-zoom-btn:active{background:var(--hover-overlay);color:var(--text-high)}#textview-close{margin-left:auto;display:flex;align-items:center;justify-content:center;height:var(--row-height);padding:0 var(--content-padding);background:transparent;border:none;border-left:1px solid var(--border-subtle);color:var(--text-muted);font-size:16px;cursor:pointer;transition:all var(--transition-fast)}#textview-close:active{background:var(--hover-overlay);color:var(--text-high)}#textview-body{flex:1;min-height:0;margin:0;padding:8px var(--content-padding);background:var(--bg-primary);border:none;color:var(--text-primary);font-family:Menlo,Monaco,Consolas,monospace;font-size:9px;line-height:1.3;overflow-y:auto;white-space:pre-wrap;word-wrap:break-word;user-select:text;-webkit-user-select:text}.hidden{display:none!important}@media(prefers-reduced-motion:reduce){*,*:before,*:after{animation-duration:.01ms!important;transition-duration:.01ms!important}}