refactor(sublime): drop embedded terminal/agent/Jupyter; introduce external terminal + Marimo
The Sublime/Terminus integration produced a chronic Korean IME bug on Windows (Terminus#325, sublimehq#3626) that has been open for 4+ years upstream, and the agent-session / cmd+click features layered on top of the embedded terminal model were never solid enough to keep maintaining. This commit pivots the package to a single, narrow remote-SSH focus. Removed (embedded terminal + agent surface): - Terminus integration in commands_terminal_tmux.py: the reuse cache, tmux pane spawn, Terminus-panel open codepath all go. - Agent session feature: agent_tmux / agent_window / agent_window_layout / agent_switcher_view / agent_remote_payload modules + four SessionsNew/Switch/Kill/ShowAgentSession commands + AgentPair registry in workspace_state.py. - cmd+click handler (terminal_link_click): token classifier, link underlining, drag_select interception. - SessionsPreviewRemoteAgentPayloadCommand (dev-mode payload preview). - All corresponding tests (11 test files dropped). Added external terminal: - New SessionsOpenRemoteTerminalCommand spawns the OS terminal via Sublime's new_terminal (provided by the user's Terminal package) with: ssh -t <alias> 'cd <remote_root> && exec $SHELL -l'. No tmux convenience layer, no embedded view: the OS terminal owns the lifecycle, so Windows IME / scrollback / paste are all native. Migrated Jupyter Lab to marimo: - jupyter_hosting.py to marimo_hosting.py. MarimoSessionManager.ensure_started spawns 'marimo edit --headless --host 127.0.0.1 --port $PORT --token-password <token>' over SSH, parses the bound port from the startup log, and tunnels via ssh -L. - No kernelspec / ipykernel registration step (marimo runs whichever Python its CLI is installed under), so SessionsRegisterJupyterKernelCommand is removed wholesale. - URL builder switched from /lab/tree/<path>?token=... to /?file=<path>&access_token=... (the active path is passed through unchanged so marimo resolves it on the remote side). - timeout env: SESSIONS_JUPYTER_STARTUP_TIMEOUT_S to SESSIONS_MARIMO_STARTUP_TIMEOUT_S. - .ipynb routing in commands.py is dropped: users handle .ipynb workflows externally. - New test_marimo_hosting.py (26 tests) covers spawn / startup / teardown / URL / timeout paths. Mirror ignore tweak: - '.git' is dropped from MIRROR_BUILTIN_IGNORE_PATTERNS so the local cache is usable as a working tree under Sublime Merge / git GUIs, which need .git/ mirrored. Users who want it ignored can still add it to sessions_mirror_ignore_patterns. Test-health floor re-pin: - Bulk deletion of agent / terminus / jupyter test files dropped the high-value test count (264 to 259) and adversarial count (184 to 177) below the previous gate floors. Re-pin to the post-refactor counts so the gate keeps guarding the new baseline. Test suite: 1167 sublime tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"min_high_value_tests": 264,
|
||||
"min_real_subprocess": 53,
|
||||
"min_high_value_tests": 259,
|
||||
"min_real_subprocess": 55,
|
||||
"min_contract_fixture": 27,
|
||||
"min_adversarial": 184,
|
||||
"max_mock_only_ratio": 0.98
|
||||
"min_adversarial": 177,
|
||||
"max_mock_only_ratio": 0.92
|
||||
}
|
||||
|
||||
@@ -39,22 +39,6 @@
|
||||
"caption": "Sessions: Open Remote Terminal",
|
||||
"command": "sessions_open_remote_terminal"
|
||||
},
|
||||
{
|
||||
"caption": "Sessions: New Remote Terminal Pane",
|
||||
"command": "sessions_new_remote_terminal_pane"
|
||||
},
|
||||
{
|
||||
"caption": "Sessions: Kill Remote Terminal",
|
||||
"command": "sessions_kill_remote_terminal"
|
||||
},
|
||||
{
|
||||
"caption": "Sessions: Attach to Tmux Session",
|
||||
"command": "sessions_attach_remote_tmux"
|
||||
},
|
||||
{
|
||||
"caption": "Sessions: Preview Remote Agent Payload",
|
||||
"command": "sessions_preview_remote_agent_payload"
|
||||
},
|
||||
{
|
||||
"caption": "Sessions: Reconnect Current Workspace",
|
||||
"command": "sessions_reconnect_current_workspace"
|
||||
@@ -72,12 +56,12 @@
|
||||
"command": "sessions_remote_extension_status"
|
||||
},
|
||||
{
|
||||
"caption": "Sessions: Open Remote Jupyter",
|
||||
"command": "sessions_open_remote_jupyter"
|
||||
"caption": "Sessions: Open Remote Marimo",
|
||||
"command": "sessions_open_remote_marimo"
|
||||
},
|
||||
{
|
||||
"caption": "Sessions: Stop Remote Jupyter",
|
||||
"command": "sessions_stop_remote_jupyter"
|
||||
"caption": "Sessions: Stop Remote Marimo",
|
||||
"command": "sessions_stop_remote_marimo"
|
||||
},
|
||||
{
|
||||
"caption": "Sessions: Diagnose LSP Workspace",
|
||||
@@ -95,24 +79,8 @@
|
||||
"caption": "Sessions: Setup Remote Python Debugging",
|
||||
"command": "sessions_setup_remote_debugging"
|
||||
},
|
||||
{
|
||||
"caption": "Sessions: Register Jupyter Kernel for Active Python",
|
||||
"command": "sessions_register_jupyter_kernel"
|
||||
},
|
||||
{
|
||||
"caption": "Sessions: Expand Deferred Directory",
|
||||
"command": "sessions_expand_deferred_directory"
|
||||
},
|
||||
{
|
||||
"caption": "Sessions: New Agent Session",
|
||||
"command": "sessions_new_agent_session"
|
||||
},
|
||||
{
|
||||
"caption": "Sessions: Show Agent Switcher",
|
||||
"command": "sessions_show_agent_switcher"
|
||||
},
|
||||
{
|
||||
"caption": "Sessions: Kill Agent Session",
|
||||
"command": "sessions_kill_agent_session"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,15 +1,6 @@
|
||||
"""Explicit Sublime plugin entrypoint for Sessions commands."""
|
||||
|
||||
from .sessions.agent_switcher_view import (
|
||||
SessionsAgentSwitcherClickListener,
|
||||
SessionsRenderAgentSwitcherCommand,
|
||||
)
|
||||
from .sessions.agent_window_layout import (
|
||||
SessionsAgentLayoutCollapseSwitcherCommand,
|
||||
SessionsAgentLayoutCommand,
|
||||
)
|
||||
from .sessions.commands import (
|
||||
SessionsAttachRemoteTmuxCommand,
|
||||
SessionsBridgeLifecycleListener,
|
||||
SessionsClearPythonInterpreterCommand,
|
||||
SessionsConnectRemoteWorkspaceCommand,
|
||||
@@ -17,24 +8,18 @@ from .sessions.commands import (
|
||||
SessionsDiagnoseLspWorkspaceCommand,
|
||||
SessionsExpandDeferredDirectoryCommand,
|
||||
SessionsInstallRemoteExtensionCommand,
|
||||
SessionsKillAgentSessionCommand,
|
||||
SessionsKillRemoteTerminalCommand,
|
||||
SessionsLspNavigationListener,
|
||||
SessionsNewAgentSessionCommand,
|
||||
SessionsNewRemoteTerminalPaneCommand,
|
||||
SessionsOnDemandFetchListener,
|
||||
SessionsOpenLocalSshConfigCommand,
|
||||
SessionsOpenRecentRemoteWorkspaceCommand,
|
||||
SessionsOpenRemoteFileCommand,
|
||||
SessionsOpenRemoteFolderCommand,
|
||||
SessionsOpenRemoteJupyterCommand,
|
||||
SessionsOpenRemoteMarimoCommand,
|
||||
SessionsOpenRemoteTerminalCommand,
|
||||
SessionsOpenRemoteTreeCommand,
|
||||
SessionsOpenSettingsCommand,
|
||||
SessionsPreviewRemoteAgentPayloadCommand,
|
||||
SessionsPythonInterpreterStatusListener,
|
||||
SessionsReconnectCurrentWorkspaceCommand,
|
||||
SessionsRegisterJupyterKernelCommand,
|
||||
SessionsRemoteCachedFileSaveListener,
|
||||
SessionsRemoteExtensionStatusCommand,
|
||||
SessionsRemoteTreeActivateCommand,
|
||||
@@ -43,21 +28,14 @@ from .sessions.commands import (
|
||||
SessionsRemoveRemoteExtensionCommand,
|
||||
SessionsSelectPythonInterpreterCommand,
|
||||
SessionsSetupRemoteDebuggingCommand,
|
||||
SessionsShowAgentSwitcherCommand,
|
||||
SessionsSidebarPlaceholderHydrateListener,
|
||||
SessionsStopRemoteJupyterCommand,
|
||||
SessionsSwitchAgentSessionCommand,
|
||||
SessionsStopRemoteMarimoCommand,
|
||||
SessionsSyncRemoteTreeToSidebarCommand,
|
||||
SessionsWorkspaceActivationListener,
|
||||
register_sessions_transport_hooks,
|
||||
)
|
||||
from .sessions.terminal_link_click import SessionsTerminalLinkClickListener
|
||||
|
||||
__all__ = [
|
||||
"SessionsAgentLayoutCollapseSwitcherCommand",
|
||||
"SessionsAgentLayoutCommand",
|
||||
"SessionsAgentSwitcherClickListener",
|
||||
"SessionsAttachRemoteTmuxCommand",
|
||||
"SessionsBridgeLifecycleListener",
|
||||
"SessionsClearPythonInterpreterCommand",
|
||||
"SessionsConnectRemoteWorkspaceCommand",
|
||||
@@ -65,39 +43,29 @@ __all__ = [
|
||||
"SessionsDiagnoseLspWorkspaceCommand",
|
||||
"SessionsExpandDeferredDirectoryCommand",
|
||||
"SessionsInstallRemoteExtensionCommand",
|
||||
"SessionsKillAgentSessionCommand",
|
||||
"SessionsKillRemoteTerminalCommand",
|
||||
"SessionsLspNavigationListener",
|
||||
"SessionsNewAgentSessionCommand",
|
||||
"SessionsNewRemoteTerminalPaneCommand",
|
||||
"SessionsOnDemandFetchListener",
|
||||
"SessionsOpenLocalSshConfigCommand",
|
||||
"SessionsOpenRecentRemoteWorkspaceCommand",
|
||||
"SessionsOpenRemoteFileCommand",
|
||||
"SessionsOpenRemoteFolderCommand",
|
||||
"SessionsOpenRemoteJupyterCommand",
|
||||
"SessionsOpenRemoteMarimoCommand",
|
||||
"SessionsOpenRemoteTerminalCommand",
|
||||
"SessionsOpenRemoteTreeCommand",
|
||||
"SessionsOpenSettingsCommand",
|
||||
"SessionsPreviewRemoteAgentPayloadCommand",
|
||||
"SessionsOpenRecentRemoteWorkspaceCommand",
|
||||
"SessionsOpenLocalSshConfigCommand",
|
||||
"SessionsPythonInterpreterStatusListener",
|
||||
"SessionsReconnectCurrentWorkspaceCommand",
|
||||
"SessionsRegisterJupyterKernelCommand",
|
||||
"SessionsRemoteCachedFileSaveListener",
|
||||
"SessionsRemoteExtensionStatusCommand",
|
||||
"SessionsRemoteTreeActivateCommand",
|
||||
"SessionsRemoteTreeEventListener",
|
||||
"SessionsRemoteTreeRefreshCommand",
|
||||
"SessionsRemoveRemoteExtensionCommand",
|
||||
"SessionsRenderAgentSwitcherCommand",
|
||||
"SessionsSelectPythonInterpreterCommand",
|
||||
"SessionsSetupRemoteDebuggingCommand",
|
||||
"SessionsShowAgentSwitcherCommand",
|
||||
"SessionsSidebarPlaceholderHydrateListener",
|
||||
"SessionsStopRemoteJupyterCommand",
|
||||
"SessionsSwitchAgentSessionCommand",
|
||||
"SessionsStopRemoteMarimoCommand",
|
||||
"SessionsSyncRemoteTreeToSidebarCommand",
|
||||
"SessionsTerminalLinkClickListener",
|
||||
"SessionsWorkspaceActivationListener",
|
||||
]
|
||||
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
"""Agent→editor JSON envelopes: validation runs only in Rust.
|
||||
|
||||
Parsing and schema rules live in the ``agent_remote_payload`` Rust crate and are
|
||||
invoked through the ``local_bridge parse-agent-editor-envelope`` CLI (stdin =
|
||||
remote stdout text). Python here is transport glue only—**no duplicate
|
||||
validation logic**. Build ``local_bridge`` before running Python tests that
|
||||
touch this module (see repo pre-commit / ``cargo build -p local_bridge``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
from .ssh_runner import _subprocess_no_window_kwargs
|
||||
|
||||
AGENT_EDITOR_PREVIEW_KIND = "sessions.agent_editor_preview"
|
||||
SUPPORTED_SCHEMA_VERSION = 1
|
||||
|
||||
_BRIDGE_AGENT_PARSE_TIMEOUT_S = 15.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AgentEditorPayload:
|
||||
"""Pre-rendered text for editor-side preview (diff already computed remotely)."""
|
||||
|
||||
kind: str
|
||||
schema_version: int
|
||||
title: str
|
||||
unified_diff: str
|
||||
target_remote_path: Optional[str] = None
|
||||
|
||||
|
||||
def _payload_from_bridge_dict(data: Dict[str, Any]) -> AgentEditorPayload:
|
||||
raw_path = data.get("target_remote_path")
|
||||
target_remote_path = None if raw_path is None else str(raw_path)
|
||||
return AgentEditorPayload(
|
||||
kind=str(data["kind"]),
|
||||
schema_version=int(data["schema_version"]),
|
||||
title=str(data["title"]),
|
||||
unified_diff=str(data["unified_diff"]),
|
||||
target_remote_path=target_remote_path,
|
||||
)
|
||||
|
||||
|
||||
def _parse_via_local_bridge(
|
||||
text: str,
|
||||
) -> Tuple[Optional[AgentEditorPayload], Optional[str]]:
|
||||
"""Run ``local_bridge parse-agent-editor-envelope``; bridge is mandatory."""
|
||||
from .ssh_file_transport import _try_resolved_local_bridge_binary_path
|
||||
|
||||
bridge = _try_resolved_local_bridge_binary_path()
|
||||
if bridge is None:
|
||||
return (
|
||||
None,
|
||||
(
|
||||
"Sessions: local_bridge binary not found. "
|
||||
"From the repo root run: cargo build -p local_bridge "
|
||||
"(or install a Sessions package that ships the bridge)."
|
||||
),
|
||||
)
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[str(bridge), "parse-agent-editor-envelope"],
|
||||
input=text,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=_BRIDGE_AGENT_PARSE_TIMEOUT_S,
|
||||
check=False,
|
||||
**_subprocess_no_window_kwargs(),
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
return None, "Sessions: local_bridge agent parse failed: {}".format(exc)
|
||||
|
||||
if proc.returncode != 0:
|
||||
tail = (proc.stderr or proc.stdout or "").strip()
|
||||
detail = tail[:400] if tail else "exit {}".format(proc.returncode)
|
||||
return None, "Sessions: local_bridge agent parse failed: {}".format(detail)
|
||||
|
||||
try:
|
||||
outer = json.loads(proc.stdout)
|
||||
except json.JSONDecodeError as exc:
|
||||
return None, "Sessions: local_bridge returned invalid JSON: {}".format(exc)
|
||||
|
||||
result = outer.get("result")
|
||||
if not isinstance(result, dict):
|
||||
return None, "Sessions: local_bridge output missing result object."
|
||||
|
||||
payload_raw = result.get("agent_editor_payload")
|
||||
err = result.get("agent_editor_error")
|
||||
if payload_raw is not None and isinstance(payload_raw, dict):
|
||||
try:
|
||||
return _payload_from_bridge_dict(payload_raw), None
|
||||
except (KeyError, TypeError, ValueError) as exc:
|
||||
return None, "Sessions: local_bridge payload shape error: {}".format(exc)
|
||||
|
||||
if err is not None and isinstance(err, str):
|
||||
return None, err
|
||||
|
||||
return None, "Sessions: local_bridge returned no payload and no error."
|
||||
|
||||
|
||||
def parse_agent_editor_envelope_from_stdout(
|
||||
text: str,
|
||||
) -> Tuple[Optional[AgentEditorPayload], Optional[str]]:
|
||||
"""Parse remote agent stdout using Rust (``local_bridge``) exclusively."""
|
||||
return _parse_via_local_bridge(text)
|
||||
|
||||
|
||||
def parse_agent_editor_payload(raw: Any) -> Optional[AgentEditorPayload]:
|
||||
"""Parse a mapping using the same Rust path (single-line JSON on stdin)."""
|
||||
if not isinstance(raw, dict):
|
||||
return None
|
||||
try:
|
||||
text = json.dumps(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
payload, err = _parse_via_local_bridge(text)
|
||||
if err is not None or payload is None:
|
||||
return None
|
||||
return payload
|
||||
@@ -1,341 +0,0 @@
|
||||
"""Agent-switcher view rendering and click-resolution helpers.
|
||||
|
||||
Track D of ``planning/AGENT_TMUX_LAYOUT.md`` (§D4) parks a named view in
|
||||
group 2 of the three-group layout that lists every agent pair the user
|
||||
has open. The view is populated from a pre-rendered string this module
|
||||
produces; clicks inside the view are routed back to a specific
|
||||
``pair_id`` (or a ``__new__`` sentinel) by the :class:`EventListener`
|
||||
defined below.
|
||||
|
||||
This module deliberately stays data-source-agnostic. The integrator
|
||||
supplies a ``Sequence[AgentPairSummary]`` pulled from
|
||||
``workspace_state`` / the tmux broker. Unit tests feed in hand-built
|
||||
fixtures.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, List, Mapping, Optional, Sequence
|
||||
|
||||
try:
|
||||
import sublime_plugin # type: ignore
|
||||
|
||||
import sublime # type: ignore
|
||||
except ImportError: # pragma: no cover - unit tests import without Sublime
|
||||
sublime = None # type: ignore[assignment]
|
||||
sublime_plugin = None # type: ignore[assignment]
|
||||
|
||||
|
||||
# View-settings key that marks a buffer as the Sessions agent switcher.
|
||||
# The integrator sets this to ``True`` on the view it creates in group 2;
|
||||
# the :class:`SessionsAgentSwitcherClickListener` filters on the same
|
||||
# key so normal editor clicks are untouched.
|
||||
SWITCHER_VIEW_SETTING_KEY = "sessions_agent_switcher"
|
||||
|
||||
# Sentinel returned by :func:`find_pair_at_line` for the trailing
|
||||
# "+ New agent session…" menu row. Integrators map this to the
|
||||
# ``sessions_new_agent_session`` command.
|
||||
NEW_PAIR_SENTINEL = "__new__"
|
||||
|
||||
# Fixed footer lines appended after every pair list. Keeping them as a
|
||||
# module constant means rendering + click resolution agree on the
|
||||
# indices of the separator and "+ New" lines without a second pass.
|
||||
_SEPARATOR_LINE = " " + "─" * 9
|
||||
_NEW_PAIR_LINE = " + New agent session…"
|
||||
|
||||
# Monospace-style column widths. Rendered lines are left-padded with two
|
||||
# spaces so clickable regions line up; the one-character status glyph
|
||||
# lives at column 2.
|
||||
_PAIR_ID_COL_WIDTH = 8
|
||||
_AGENT_LABEL_COL_WIDTH = 16
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AgentPairSummary:
|
||||
"""Snapshot of a single agent pair as the switcher should display it."""
|
||||
|
||||
pair_id: str
|
||||
workspace_label: str
|
||||
agent_label: str
|
||||
is_attached: bool
|
||||
is_active: bool
|
||||
|
||||
|
||||
def render_switcher_body(pairs: Sequence[AgentPairSummary]) -> str:
|
||||
"""Return the monospace-friendly text that the switcher view displays.
|
||||
|
||||
Each pair becomes one line; the last two lines are a fixed separator
|
||||
plus "+ New agent session…" entry. The active pair gets a filled
|
||||
glyph ``●``; everything else gets ``○``. Attachment and active
|
||||
labels are suffixed as ``(active)`` / ``[attached]`` so a monospace
|
||||
font keeps columns aligned.
|
||||
"""
|
||||
lines: List[str] = [_format_pair_line(pair) for pair in pairs]
|
||||
lines.append(_SEPARATOR_LINE)
|
||||
lines.append(_NEW_PAIR_LINE)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _format_pair_line(pair: AgentPairSummary) -> str:
|
||||
glyph = "●" if pair.is_active else "○"
|
||||
pair_short = _shorten_pair_id(pair.pair_id)
|
||||
agent_cell = pair.agent_label.ljust(_AGENT_LABEL_COL_WIDTH)
|
||||
prefix = " {glyph} {pair:<{pw}} · {agent}".format(
|
||||
glyph=glyph,
|
||||
pair=pair_short,
|
||||
pw=_PAIR_ID_COL_WIDTH,
|
||||
agent=agent_cell,
|
||||
)
|
||||
suffix_parts: List[str] = []
|
||||
if pair.is_active:
|
||||
suffix_parts.append("(active)")
|
||||
if pair.is_attached:
|
||||
suffix_parts.append("[attached]")
|
||||
if suffix_parts:
|
||||
return prefix + " " + " ".join(suffix_parts)
|
||||
return prefix.rstrip()
|
||||
|
||||
|
||||
def _shorten_pair_id(pair_id: str) -> str:
|
||||
"""Render the leading cache-key prefix so all rows align.
|
||||
|
||||
``pair_id`` is shaped ``<ws_cache_key>:<agent_id>``; the cache key is
|
||||
a blake2 hex prefix. We show the first eight characters so the
|
||||
switcher stays readable, falling back to the raw string if it looks
|
||||
unusual (no colon, short hash, etc.).
|
||||
"""
|
||||
head = pair_id.split(":", 1)[0] if ":" in pair_id else pair_id
|
||||
if len(head) >= _PAIR_ID_COL_WIDTH:
|
||||
return head[:_PAIR_ID_COL_WIDTH]
|
||||
return head
|
||||
|
||||
|
||||
def find_pair_at_line(
|
||||
line_index: int, pairs: Sequence[AgentPairSummary]
|
||||
) -> Optional[str]:
|
||||
"""Map a clicked 0-based line index back to a ``pair_id`` / sentinel.
|
||||
|
||||
Returns ``None`` for the separator and any out-of-range click. The
|
||||
"+ New agent session…" row resolves to :data:`NEW_PAIR_SENTINEL`.
|
||||
"""
|
||||
if line_index < 0:
|
||||
return None
|
||||
if line_index < len(pairs):
|
||||
return pairs[line_index].pair_id
|
||||
separator_index = len(pairs)
|
||||
new_pair_index = len(pairs) + 1
|
||||
if line_index == separator_index:
|
||||
return None
|
||||
if line_index == new_pair_index:
|
||||
return NEW_PAIR_SENTINEL
|
||||
return None
|
||||
|
||||
|
||||
def _is_switcher_view(view: object) -> bool:
|
||||
settings_fn = getattr(view, "settings", None)
|
||||
if not callable(settings_fn):
|
||||
return False
|
||||
try:
|
||||
settings = settings_fn()
|
||||
except Exception: # pragma: no cover - defensive
|
||||
return False
|
||||
get = getattr(settings, "get", None)
|
||||
if not callable(get):
|
||||
return False
|
||||
return bool(get(SWITCHER_VIEW_SETTING_KEY))
|
||||
|
||||
|
||||
def _point_from_event(view: object, event: Mapping[str, Any]) -> Optional[int]:
|
||||
x = event.get("x") if isinstance(event, Mapping) else None
|
||||
y = event.get("y") if isinstance(event, Mapping) else None
|
||||
if x is None or y is None:
|
||||
return None
|
||||
window_to_text = getattr(view, "window_to_text", None)
|
||||
if not callable(window_to_text):
|
||||
return None
|
||||
try:
|
||||
return int(window_to_text((x, y)))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _line_index_from_point(view: object, point: int) -> Optional[int]:
|
||||
rowcol = getattr(view, "rowcol", None)
|
||||
if not callable(rowcol):
|
||||
return None
|
||||
try:
|
||||
row, _col = rowcol(point)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if not isinstance(row, int):
|
||||
return None
|
||||
return row
|
||||
|
||||
|
||||
def _cached_pairs(view: object) -> Optional[Sequence[AgentPairSummary]]:
|
||||
"""Read the pair summaries the integrator stashed on the view.
|
||||
|
||||
The integrator sets ``view.settings().set("sessions_agent_pairs",
|
||||
[{...}, ...])`` whenever it renders — the click listener rehydrates
|
||||
that JSON-ish list back into :class:`AgentPairSummary` tuples so it
|
||||
can resolve a clicked line without another lookup.
|
||||
"""
|
||||
settings_fn = getattr(view, "settings", None)
|
||||
if not callable(settings_fn):
|
||||
return None
|
||||
try:
|
||||
settings = settings_fn()
|
||||
except Exception: # pragma: no cover - defensive
|
||||
return None
|
||||
get = getattr(settings, "get", None)
|
||||
if not callable(get):
|
||||
return None
|
||||
raw = get("sessions_agent_pairs")
|
||||
if not isinstance(raw, list):
|
||||
return None
|
||||
pairs: List[AgentPairSummary] = []
|
||||
for entry in raw:
|
||||
if not isinstance(entry, Mapping):
|
||||
return None
|
||||
pair_id = entry.get("pair_id")
|
||||
workspace_label = entry.get("workspace_label", "")
|
||||
agent_label = entry.get("agent_label", "")
|
||||
is_attached = bool(entry.get("is_attached", False))
|
||||
is_active = bool(entry.get("is_active", False))
|
||||
if not isinstance(pair_id, str):
|
||||
return None
|
||||
pairs.append(
|
||||
AgentPairSummary(
|
||||
pair_id=pair_id,
|
||||
workspace_label=str(workspace_label),
|
||||
agent_label=str(agent_label),
|
||||
is_attached=is_attached,
|
||||
is_active=is_active,
|
||||
)
|
||||
)
|
||||
return pairs
|
||||
|
||||
|
||||
def dispatch_switcher_click(
|
||||
view: object,
|
||||
event: Mapping[str, Any],
|
||||
pairs: Sequence[AgentPairSummary],
|
||||
) -> Optional[Mapping[str, Any]]:
|
||||
"""Resolve a click event into a ``(command_name, args)`` dict.
|
||||
|
||||
Pure helper extracted so tests can exercise the resolution logic
|
||||
without instantiating the :class:`EventListener`. Returns ``None``
|
||||
when the click lands on a non-interactive line (separator / blank)
|
||||
or the geometry can't be resolved.
|
||||
"""
|
||||
point = _point_from_event(view, event)
|
||||
if point is None:
|
||||
return None
|
||||
line_index = _line_index_from_point(view, point)
|
||||
if line_index is None:
|
||||
return None
|
||||
target = find_pair_at_line(line_index, pairs)
|
||||
if target is None:
|
||||
return None
|
||||
if target == NEW_PAIR_SENTINEL:
|
||||
return {"command": "sessions_new_agent_session", "args": {}}
|
||||
return {
|
||||
"command": "sessions_switch_agent_session",
|
||||
"args": {"pair_id": target},
|
||||
}
|
||||
|
||||
|
||||
_EventListenerBase = (
|
||||
sublime_plugin.EventListener if sublime_plugin is not None else object
|
||||
)
|
||||
|
||||
|
||||
class SessionsAgentSwitcherClickListener(_EventListenerBase): # type: ignore[misc]
|
||||
"""Turn ``drag_select`` clicks in a switcher view into switch commands.
|
||||
|
||||
Mirrors :class:`sessions.terminal_link_click.SessionsTerminalLinkClickListener`:
|
||||
we filter on a view-settings marker, then fire a window command when
|
||||
a click resolves to a pair id.
|
||||
"""
|
||||
|
||||
def on_text_command(
|
||||
self,
|
||||
view: object,
|
||||
command_name: str,
|
||||
args: Optional[Mapping[str, Any]],
|
||||
) -> None:
|
||||
"""Route drag_select clicks inside switcher views to the right command."""
|
||||
if command_name != "drag_select":
|
||||
return None
|
||||
if not _is_switcher_view(view):
|
||||
return None
|
||||
if not isinstance(args, Mapping):
|
||||
return None
|
||||
event = args.get("event")
|
||||
if not isinstance(event, Mapping):
|
||||
return None
|
||||
pairs = _cached_pairs(view)
|
||||
if pairs is None:
|
||||
return None
|
||||
dispatch = dispatch_switcher_click(view, event, pairs)
|
||||
if dispatch is None:
|
||||
return None
|
||||
window_fn = getattr(view, "window", None)
|
||||
window = window_fn() if callable(window_fn) else None
|
||||
if window is None:
|
||||
return None
|
||||
run_command = getattr(window, "run_command", None)
|
||||
if not callable(run_command):
|
||||
return None
|
||||
run_command(dispatch["command"], dispatch.get("args") or {})
|
||||
return None
|
||||
|
||||
|
||||
_TextCommandBase = sublime_plugin.TextCommand if sublime_plugin is not None else object
|
||||
|
||||
|
||||
class SessionsRenderAgentSwitcherCommand(_TextCommandBase): # type: ignore[misc]
|
||||
"""Replace the switcher view's full content with a pre-rendered body."""
|
||||
|
||||
def run(self, edit: object, body: str = "") -> None:
|
||||
"""Replace the full buffer contents with ``body``.
|
||||
|
||||
The integrator calls this whenever pair data changes; we erase
|
||||
the existing region and insert the new text. A read-only flag is
|
||||
toggled around the edit so user keystrokes don't mutate the
|
||||
switcher buffer between refreshes.
|
||||
"""
|
||||
view = getattr(self, "view", None)
|
||||
if view is None:
|
||||
return
|
||||
set_read_only = getattr(view, "set_read_only", None)
|
||||
if callable(set_read_only):
|
||||
set_read_only(False)
|
||||
try:
|
||||
size_fn = getattr(view, "size", None)
|
||||
erase = getattr(view, "erase", None)
|
||||
insert = getattr(view, "insert", None)
|
||||
if not (callable(size_fn) and callable(erase) and callable(insert)):
|
||||
return
|
||||
if sublime is not None:
|
||||
region = sublime.Region(0, size_fn())
|
||||
else: # pragma: no cover - Sublime missing at runtime
|
||||
region = (0, size_fn())
|
||||
erase(edit, region)
|
||||
insert(edit, 0, body or "")
|
||||
finally:
|
||||
if callable(set_read_only):
|
||||
set_read_only(True)
|
||||
|
||||
|
||||
__all__ = (
|
||||
"AgentPairSummary",
|
||||
"NEW_PAIR_SENTINEL",
|
||||
"SWITCHER_VIEW_SETTING_KEY",
|
||||
"SessionsAgentSwitcherClickListener",
|
||||
"SessionsRenderAgentSwitcherCommand",
|
||||
"dispatch_switcher_click",
|
||||
"find_pair_at_line",
|
||||
"render_switcher_body",
|
||||
)
|
||||
@@ -1,492 +0,0 @@
|
||||
"""Pure-Python primitives for tmux-hosted remote agent sessions.
|
||||
|
||||
Sessions runs each remote agent (claude, codex, ...) inside a long-lived tmux
|
||||
session on the target host. The Sublime side attaches to that session via a
|
||||
Terminus pane so the agent's own terminal UI drives the UX verbatim. This
|
||||
module owns the SSH / tmux plumbing — spawning, attaching, listing and
|
||||
killing sessions — and is intentionally free of Sublime imports so the
|
||||
logic is unit-testable without the ``sublime`` runtime.
|
||||
|
||||
This broker is agent-agnostic and knows nothing about what the agent
|
||||
prints inside the tmux pane. The earlier chat-with-diff design had a
|
||||
companion ``agent_proposal_watcher`` module that parsed diff output
|
||||
tailed from ``tmux pipe-pane``; that direction was abandoned with the
|
||||
chat→tmux pivot and the parser module was retired in v0.6.7.
|
||||
|
||||
Design notes
|
||||
------------
|
||||
- Session naming: ``sessions-agent-<workspace_cache_key[:8]>-<agent_id>``.
|
||||
The ``[:8]`` prefix keeps names short enough for ``tmux`` while remaining
|
||||
unambiguous across the small number of workspaces a single user juggles
|
||||
concurrently. ``agent_id`` is validated against a tight charset so a
|
||||
malicious catalog entry cannot inject shell metacharacters.
|
||||
- Idempotent spawn: ``tmux new-session -A -s <name>`` attaches if the
|
||||
session already exists and creates it otherwise. The broker still performs
|
||||
an explicit ``has-session`` probe first so callers can distinguish the
|
||||
"already running" path from a fresh spawn for UX messaging.
|
||||
- "tmux not installed": ``list_sessions`` treats a missing tmux binary on
|
||||
the remote (exit 127) as an empty catalog rather than an error, so the
|
||||
integrator can surface a one-shot installer hint instead of a traceback.
|
||||
- SSH quoting mirrors ``jupyter_hosting._run_over_ssh``: concatenate the
|
||||
remote argv into a single shlex-quoted string handed to OpenSSH as one
|
||||
trailing positional, so the remote shell re-parses it as we intended.
|
||||
Leading ``~/`` segments in ``agent_cmd`` are rewritten to ``"$HOME"/...``
|
||||
so the remote shell expands the tilde.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable, List, Optional, Sequence, Tuple
|
||||
|
||||
from .ssh_runner import _subprocess_no_window_kwargs
|
||||
|
||||
try:
|
||||
import sublime_plugin # type: ignore
|
||||
|
||||
import sublime # type: ignore
|
||||
except ImportError: # pragma: no cover - unit tests import without Sublime
|
||||
sublime = None # type: ignore[assignment]
|
||||
sublime_plugin = None # type: ignore[assignment]
|
||||
|
||||
|
||||
_LOG = logging.getLogger("sessions.agent_tmux")
|
||||
|
||||
|
||||
_SESSION_NAME_PREFIX = "sessions-agent-"
|
||||
_AGENT_ID_RE = re.compile(r"\A[A-Za-z0-9._-]+\Z")
|
||||
_WORKSPACE_KEY_RE = re.compile(r"\A[A-Za-z0-9._-]+\Z")
|
||||
|
||||
|
||||
# Command builder signature: given an SSH alias, return ``argv`` that prefixes
|
||||
# a remote command (e.g. ``["ssh", alias]`` or ``["ssh", "-F", config, alias]``).
|
||||
# Injected via ``AgentTmuxBroker.__init__`` so tests can stub it.
|
||||
SshCommandBuilder = Callable[[str], List[str]]
|
||||
|
||||
|
||||
def _default_ssh_command_builder(alias: str) -> List[str]:
|
||||
"""Return the default ``ssh -T <alias>`` argv prefix for remote commands.
|
||||
|
||||
``-T`` explicitly disables PTY allocation. OpenSSH already defaults to
|
||||
no-TTY when a remote command is supplied, but Sublime's plugin host
|
||||
runs without a controlling terminal in some launch contexts (Finder /
|
||||
Dock launches on macOS, Windows GUI), and a stray ``RequestTTY=yes``
|
||||
in ``~/.ssh/config`` would otherwise cause the spawn to allocate a
|
||||
pseudo-tty. ``-T`` makes the no-TTY contract explicit so the remote
|
||||
``tmux new-session -d`` is guaranteed not to inherit a half-initialised
|
||||
terminal — the trigger for ``open terminal failed: not a terminal``.
|
||||
"""
|
||||
return ["ssh", "-T", alias]
|
||||
|
||||
|
||||
def _shell_quote_with_tilde_expansion(arg: str) -> str:
|
||||
"""``shlex.quote`` variant that preserves a leading ``~/`` for ``$HOME``.
|
||||
|
||||
``shlex.quote("~/x")`` returns ``'~/x'``; wrapped in single quotes the
|
||||
remote shell treats ``~`` as a literal character and the command fails
|
||||
with ``no such file or directory: ~/x``. Rewriting to ``"$HOME"/<suffix>``
|
||||
lets the shell expand ``$HOME`` while the suffix stays double-quoted so
|
||||
spaces and metachars are still safe. Non-tilde args go through
|
||||
``shlex.quote`` unchanged.
|
||||
"""
|
||||
if arg.startswith("~/"):
|
||||
suffix = arg[2:]
|
||||
escaped = (
|
||||
suffix.replace("\\", "\\\\")
|
||||
.replace('"', '\\"')
|
||||
.replace("`", "\\`")
|
||||
.replace("$", "\\$")
|
||||
)
|
||||
return f'"$HOME/{escaped}"'
|
||||
return shlex.quote(arg)
|
||||
|
||||
|
||||
class AgentTmuxError(RuntimeError):
|
||||
"""Raised when a tmux operation against the remote host fails unexpectedly."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TmuxAgentSession:
|
||||
"""Snapshot describing one tmux-hosted remote agent session.
|
||||
|
||||
``session_name`` follows ``sessions-agent-<workspace_cache_key[:8]>-<agent_id>``
|
||||
so Sessions-owned sessions are easy to enumerate and differentiate from
|
||||
whatever else the user runs under tmux. ``attach_argv`` and
|
||||
``spawn_argv`` are fully-resolved argv lists ready to hand to
|
||||
``subprocess`` or to a Terminus ``shell_cmd`` after shell-joining.
|
||||
"""
|
||||
|
||||
host_alias: str
|
||||
workspace_cache_key: str
|
||||
agent_id: str
|
||||
session_name: str
|
||||
agent_cmd: Tuple[str, ...]
|
||||
attach_argv: Tuple[str, ...]
|
||||
spawn_argv: Tuple[str, ...]
|
||||
|
||||
|
||||
def _validate_agent_id(agent_id: str) -> None:
|
||||
"""Reject ``agent_id`` values containing shell-hostile characters."""
|
||||
if not _AGENT_ID_RE.match(agent_id):
|
||||
raise AgentTmuxError(
|
||||
"agent_id contains disallowed characters: {!r}".format(agent_id)
|
||||
)
|
||||
|
||||
|
||||
def _validate_workspace_cache_key(workspace_cache_key: str) -> None:
|
||||
"""Reject ``workspace_cache_key`` values outside the safe charset."""
|
||||
if not _WORKSPACE_KEY_RE.match(workspace_cache_key):
|
||||
raise AgentTmuxError(
|
||||
"workspace_cache_key contains disallowed characters: {!r}".format(
|
||||
workspace_cache_key
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _build_session_name(workspace_cache_key: str, agent_id: str) -> str:
|
||||
"""Return the canonical tmux session name for a ``(workspace, agent)`` pair."""
|
||||
return "{}{}-{}".format(
|
||||
_SESSION_NAME_PREFIX,
|
||||
workspace_cache_key[:8],
|
||||
agent_id,
|
||||
)
|
||||
|
||||
|
||||
def _quote_remote_command(argv: Sequence[str]) -> str:
|
||||
"""Join ``argv`` into one shell-safe string with ``~/`` expansion."""
|
||||
return " ".join(_shell_quote_with_tilde_expansion(a) for a in argv)
|
||||
|
||||
|
||||
class AgentTmuxBroker:
|
||||
"""Plan, spawn, attach and kill tmux sessions hosting remote agents.
|
||||
|
||||
The broker is a thin, injectable-dependency wrapper around ``ssh ...
|
||||
tmux ...`` calls. All subprocess plumbing is reachable through the
|
||||
``run`` callable passed to ``__init__`` so tests can replace it with a
|
||||
recorder. Nothing here imports from ``sublime``; the integrator wires
|
||||
this module into Sublime commands separately.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
ssh_command_builder: Optional[SshCommandBuilder] = None,
|
||||
run: Optional[Callable[..., subprocess.CompletedProcess]] = None,
|
||||
) -> None:
|
||||
"""Build a broker, optionally injecting stubs for tests.
|
||||
|
||||
Args:
|
||||
ssh_command_builder: Maps an SSH alias to an argv prefix for
|
||||
remote commands. Defaults to ``["ssh", alias]``.
|
||||
run: Override for ``subprocess.run`` used for every remote
|
||||
tmux command. Tests typically pass a recording stub.
|
||||
"""
|
||||
self._ssh = ssh_command_builder or _default_ssh_command_builder
|
||||
self._run = run or subprocess.run
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Planning
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def plan(
|
||||
self,
|
||||
host_alias: str,
|
||||
workspace_cache_key: str,
|
||||
agent_id: str,
|
||||
agent_cmd: Sequence[str],
|
||||
) -> TmuxAgentSession:
|
||||
"""Return a ``TmuxAgentSession`` describing the session to run.
|
||||
|
||||
Validates ``agent_id`` and ``workspace_cache_key`` against the safe
|
||||
charset and materialises the ``attach_argv`` / ``spawn_argv`` pair
|
||||
without performing any remote I/O.
|
||||
|
||||
Args:
|
||||
host_alias: SSH alias for the target host.
|
||||
workspace_cache_key: Sessions workspace cache key — used for the
|
||||
session-name prefix.
|
||||
agent_id: Catalog entry id (for example ``"claude"`` or
|
||||
``"codex"``).
|
||||
agent_cmd: Remote argv to exec inside ``tmux new-session``.
|
||||
May contain ``~/`` paths — those are rewritten to
|
||||
``"$HOME"/...`` in the spawn shell command.
|
||||
|
||||
Returns:
|
||||
A frozen :class:`TmuxAgentSession` ready for
|
||||
:meth:`attach_or_spawn` / :meth:`is_running`.
|
||||
|
||||
Raises:
|
||||
AgentTmuxError: When ``agent_id`` or ``workspace_cache_key``
|
||||
contains disallowed characters, or ``agent_cmd`` is empty.
|
||||
"""
|
||||
_validate_agent_id(agent_id)
|
||||
_validate_workspace_cache_key(workspace_cache_key)
|
||||
agent_cmd_tuple = tuple(agent_cmd)
|
||||
if not agent_cmd_tuple:
|
||||
raise AgentTmuxError("agent_cmd must contain at least one argument")
|
||||
|
||||
session_name = _build_session_name(workspace_cache_key, agent_id)
|
||||
ssh_prefix = list(self._ssh(host_alias))
|
||||
|
||||
attach_argv = tuple(ssh_prefix + ["tmux", "attach", "-t", session_name])
|
||||
|
||||
# ``-d`` (detached) is critical: the spawn is invoked through a
|
||||
# non-interactive ``ssh -T <alias> bash -lc ...`` pipeline with no
|
||||
# allocated TTY. Without ``-d``, tmux tries to attach to the new
|
||||
# session immediately and fails with
|
||||
# ``open terminal failed: not a terminal``. The actual attach
|
||||
# happens later from Terminus, which does allocate a TTY.
|
||||
#
|
||||
# ``</dev/null`` belt-and-suspenders: even with ``-d``, tmux 3.x
|
||||
# initialises a terminal-capability snapshot for the new session
|
||||
# by probing whatever fd 0 is connected to. When ``ssh`` is
|
||||
# launched from a Sublime plugin host on macOS the inherited
|
||||
# stdin can be a closed/odd handle that tmux misclassifies as a
|
||||
# broken terminal — the error string regressed in v0.6.2 testing
|
||||
# on aws-celery despite ``-d`` being present. Explicitly hooking
|
||||
# tmux's stdin to ``/dev/null`` makes ``isatty(0)`` definitively
|
||||
# false and keeps tmux on the "no terminal needed" code path.
|
||||
# ``-A`` semantics still apply: when the session already exists
|
||||
# the broker short-circuits via ``is_running`` before this
|
||||
# command runs (see :meth:`attach_or_spawn`), so this command
|
||||
# only ever fires for the create-fresh case.
|
||||
spawn_remote_cmd = (
|
||||
"tmux new-session -A -d -s {name} -- {cmd} </dev/null".format(
|
||||
name=shlex.quote(session_name),
|
||||
cmd=_quote_remote_command(agent_cmd_tuple),
|
||||
)
|
||||
)
|
||||
spawn_argv = tuple(ssh_prefix + ["bash", "-lc", spawn_remote_cmd])
|
||||
|
||||
return TmuxAgentSession(
|
||||
host_alias=host_alias,
|
||||
workspace_cache_key=workspace_cache_key,
|
||||
agent_id=agent_id,
|
||||
session_name=session_name,
|
||||
agent_cmd=agent_cmd_tuple,
|
||||
attach_argv=attach_argv,
|
||||
spawn_argv=spawn_argv,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Probing / spawning
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def is_running(self, host_alias: str, session_name: str) -> bool:
|
||||
"""Return ``True`` iff ``tmux has-session -t <name>`` exits 0.
|
||||
|
||||
Any non-zero exit (session missing, tmux not installed, SSH error)
|
||||
is treated as "not running"; the caller may follow up with
|
||||
:meth:`list_sessions` to distinguish the "no tmux at all" case.
|
||||
"""
|
||||
argv = list(self._ssh(host_alias)) + [
|
||||
"tmux",
|
||||
"has-session",
|
||||
"-t",
|
||||
session_name,
|
||||
]
|
||||
completed = self._run(
|
||||
argv,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
check=False,
|
||||
text=True,
|
||||
**_subprocess_no_window_kwargs(),
|
||||
)
|
||||
return completed.returncode == 0
|
||||
|
||||
def attach_or_spawn(self, session: TmuxAgentSession) -> None:
|
||||
"""Ensure the tmux session exists on the remote host.
|
||||
|
||||
If :meth:`is_running` already returns ``True`` this is a no-op —
|
||||
the caller is expected to drive the actual attach separately (for
|
||||
example via a Terminus ``shell_cmd``). Otherwise a remote
|
||||
``tmux new-session -A ...`` spawn is issued; a non-zero exit raises
|
||||
:class:`AgentTmuxError`.
|
||||
|
||||
Args:
|
||||
session: The planned session descriptor returned by
|
||||
:meth:`plan`.
|
||||
|
||||
Raises:
|
||||
AgentTmuxError: When the remote spawn command exits non-zero.
|
||||
"""
|
||||
if self.is_running(session.host_alias, session.session_name):
|
||||
_LOG.debug(
|
||||
"tmux session %s already running on %s; skipping spawn",
|
||||
session.session_name,
|
||||
session.host_alias,
|
||||
)
|
||||
return
|
||||
completed = self._run(
|
||||
list(session.spawn_argv),
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
check=False,
|
||||
text=True,
|
||||
**_subprocess_no_window_kwargs(),
|
||||
)
|
||||
if completed.returncode != 0:
|
||||
raise AgentTmuxError(
|
||||
"tmux spawn for {name} on {host} exited {rc}: "
|
||||
"stdout={stdout!r} stderr={stderr!r}".format(
|
||||
name=session.session_name,
|
||||
host=session.host_alias,
|
||||
rc=completed.returncode,
|
||||
stdout=(completed.stdout or "").strip(),
|
||||
stderr=(completed.stderr or "").strip(),
|
||||
)
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Enumeration / teardown
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def list_sessions(self, host_alias: str) -> List[str]:
|
||||
"""Return Sessions-owned tmux session names present on the remote.
|
||||
|
||||
Runs ``tmux list-sessions -F '#{session_name}'`` on the remote and
|
||||
filters the output down to names starting with
|
||||
``sessions-agent-``. Three "normal" non-error paths return the
|
||||
empty list instead of raising:
|
||||
|
||||
* tmux reports "no server running" / "no sessions" (exit 1 with
|
||||
a recognisable stderr message);
|
||||
* tmux is not installed (exit 127 or shell "command not found");
|
||||
* SSH itself exits non-zero with a tmux-not-found-style stderr.
|
||||
|
||||
Any other non-zero exit raises :class:`AgentTmuxError`.
|
||||
"""
|
||||
argv = list(self._ssh(host_alias)) + [
|
||||
"tmux",
|
||||
"list-sessions",
|
||||
"-F",
|
||||
"#{session_name}",
|
||||
]
|
||||
completed = self._run(
|
||||
argv,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
check=False,
|
||||
text=True,
|
||||
**_subprocess_no_window_kwargs(),
|
||||
)
|
||||
if completed.returncode == 0:
|
||||
return [
|
||||
line.strip()
|
||||
for line in (completed.stdout or "").splitlines()
|
||||
if line.strip().startswith(_SESSION_NAME_PREFIX)
|
||||
]
|
||||
|
||||
stderr = (completed.stderr or "").lower()
|
||||
if _stderr_indicates_no_sessions(stderr) or _stderr_indicates_no_tmux(
|
||||
completed.returncode, stderr
|
||||
):
|
||||
return []
|
||||
raise AgentTmuxError(
|
||||
"tmux list-sessions on {host} exited {rc}: stderr={stderr!r}".format(
|
||||
host=host_alias,
|
||||
rc=completed.returncode,
|
||||
stderr=(completed.stderr or "").strip(),
|
||||
)
|
||||
)
|
||||
|
||||
def kill(self, host_alias: str, session_name: str) -> None:
|
||||
"""Kill one tmux session, tolerating "session not found".
|
||||
|
||||
A non-zero exit whose stderr matches the "can't find session" /
|
||||
"no such session" message is swallowed silently (the session was
|
||||
already gone). Other non-zero exits raise :class:`AgentTmuxError`.
|
||||
"""
|
||||
argv = list(self._ssh(host_alias)) + [
|
||||
"tmux",
|
||||
"kill-session",
|
||||
"-t",
|
||||
session_name,
|
||||
]
|
||||
completed = self._run(
|
||||
argv,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
check=False,
|
||||
text=True,
|
||||
**_subprocess_no_window_kwargs(),
|
||||
)
|
||||
if completed.returncode == 0:
|
||||
return
|
||||
stderr = (completed.stderr or "").lower()
|
||||
if _stderr_indicates_session_missing(stderr) or _stderr_indicates_no_sessions(
|
||||
stderr
|
||||
):
|
||||
_LOG.debug(
|
||||
"tmux kill-session %s on %s: already gone", session_name, host_alias
|
||||
)
|
||||
return
|
||||
raise AgentTmuxError(
|
||||
"tmux kill-session {name} on {host} exited {rc}: stderr={stderr!r}".format(
|
||||
name=session_name,
|
||||
host=host_alias,
|
||||
rc=completed.returncode,
|
||||
stderr=(completed.stderr or "").strip(),
|
||||
)
|
||||
)
|
||||
|
||||
def shutdown_all(self, host_alias: str) -> None:
|
||||
"""Kill every Sessions-owned tmux session on ``host_alias``.
|
||||
|
||||
Best-effort: individual kill failures are logged at WARNING and the
|
||||
sweep continues. Swallows the same "no sessions" / "no tmux" cases
|
||||
that :meth:`list_sessions` does.
|
||||
"""
|
||||
try:
|
||||
names = self.list_sessions(host_alias)
|
||||
except AgentTmuxError as exc:
|
||||
_LOG.warning(
|
||||
"shutdown_all: list_sessions on %s failed: %s", host_alias, exc
|
||||
)
|
||||
return
|
||||
for name in names:
|
||||
try:
|
||||
self.kill(host_alias, name)
|
||||
except AgentTmuxError as exc:
|
||||
_LOG.warning(
|
||||
"shutdown_all: kill %s on %s failed: %s", name, host_alias, exc
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# stderr shape helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _stderr_indicates_no_sessions(stderr_lower: str) -> bool:
|
||||
"""Return ``True`` when stderr signals "no tmux server / no sessions"."""
|
||||
return (
|
||||
"no server running" in stderr_lower
|
||||
or "no sessions" in stderr_lower
|
||||
or "error connecting to" in stderr_lower # tmux socket missing
|
||||
)
|
||||
|
||||
|
||||
def _stderr_indicates_no_tmux(returncode: int, stderr_lower: str) -> bool:
|
||||
"""Return ``True`` when stderr/exit code signals "tmux binary missing"."""
|
||||
if returncode == 127:
|
||||
return True
|
||||
return (
|
||||
"command not found" in stderr_lower
|
||||
or "tmux: not found" in stderr_lower
|
||||
or "no such file or directory" in stderr_lower
|
||||
)
|
||||
|
||||
|
||||
def _stderr_indicates_session_missing(stderr_lower: str) -> bool:
|
||||
"""Return ``True`` when stderr signals the specific session is gone."""
|
||||
return (
|
||||
"can't find session" in stderr_lower
|
||||
or "no such session" in stderr_lower
|
||||
or "session not found" in stderr_lower
|
||||
)
|
||||
@@ -1,621 +0,0 @@
|
||||
"""Agent window layout, models, and composed view state for Sessions."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Dict, Iterable, List, Mapping, Optional, Sequence, Set, Tuple
|
||||
|
||||
from .recent_state import RecentWorkspace, RecentWorkspaceStore
|
||||
|
||||
|
||||
class AgentWindowRegion(str, Enum):
|
||||
"""Primary regions of the agent window shell."""
|
||||
|
||||
LEFT_SESSIONS = "left_sessions"
|
||||
CENTER_ACTIVITY = "center_activity"
|
||||
RIGHT_WORKSPACE = "right_workspace"
|
||||
|
||||
|
||||
ThreePaneRegions = Tuple[AgentWindowRegion, AgentWindowRegion, AgentWindowRegion]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AgentWindowLayoutSpec:
|
||||
"""Describe the first agent window split per Issue G product decisions.
|
||||
|
||||
Attributes:
|
||||
left_region: Session list placement.
|
||||
center_region: Activity / chat-style summaries.
|
||||
right_region: Editor surface plus directory browsing.
|
||||
summary_first: Prefer structured summaries over raw terminal streams.
|
||||
avoid_full_workbench: Explicitly scope out VS Code-style workbench parity.
|
||||
"""
|
||||
|
||||
left_region: AgentWindowRegion = AgentWindowRegion.LEFT_SESSIONS
|
||||
center_region: AgentWindowRegion = AgentWindowRegion.CENTER_ACTIVITY
|
||||
right_region: AgentWindowRegion = AgentWindowRegion.RIGHT_WORKSPACE
|
||||
summary_first: bool = True
|
||||
avoid_full_workbench: bool = True
|
||||
|
||||
def ordered_regions(self) -> ThreePaneRegions:
|
||||
"""Return left-to-right region order for the prototype shell.
|
||||
|
||||
Args:
|
||||
None.
|
||||
|
||||
Returns:
|
||||
Tuple of regions in visual order.
|
||||
|
||||
Raises:
|
||||
None.
|
||||
"""
|
||||
return (self.left_region, self.center_region, self.right_region)
|
||||
|
||||
|
||||
DEFAULT_AGENT_WINDOW_LAYOUT = AgentWindowLayoutSpec()
|
||||
|
||||
|
||||
class SessionAvailability(str, Enum):
|
||||
"""High-level availability for a row in the session list."""
|
||||
|
||||
CONNECTED = "connected"
|
||||
OFFLINE_ASSUMED = "offline_assumed"
|
||||
STALE_METADATA = "stale_metadata"
|
||||
CACHE_MISSING = "cache_missing"
|
||||
FOREIGN_SHARED_CACHE = "foreign_shared_cache"
|
||||
|
||||
|
||||
class TimelineEntryKind(str, Enum):
|
||||
"""Kinds of items shown in the center activity stream."""
|
||||
|
||||
USER_CHAT = "user_chat"
|
||||
ASSISTANT_SUMMARY = "assistant_summary"
|
||||
HELPER_ACTION = "helper_action"
|
||||
CLI_ACTION = "cli_action"
|
||||
SYSTEM_EVENT = "system_event"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StructuredActionSummary:
|
||||
"""Structured helper or CLI outcome instead of raw terminal output.
|
||||
|
||||
Attributes:
|
||||
verb: Short verb phrase such as "Format file" or "Run tests".
|
||||
target_remote_path: Optional primary remote path the action touched.
|
||||
exit_code: Process exit code when applicable.
|
||||
duration_ms: Wall duration when known.
|
||||
stderr_preview: Bounded stderr excerpt for debugging rows, not full logs.
|
||||
stdout_line_count: Number of stdout lines suppressed from the summary view.
|
||||
notes: Optional human-readable clarification.
|
||||
"""
|
||||
|
||||
verb: str
|
||||
target_remote_path: Optional[str] = None
|
||||
exit_code: Optional[int] = None
|
||||
duration_ms: Optional[int] = None
|
||||
stderr_preview: Optional[str] = None
|
||||
stdout_line_count: int = 0
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EditorJumpTarget:
|
||||
"""Fast-open target in the local cache mirroring a remote file.
|
||||
|
||||
Attributes:
|
||||
local_cache_path: Path to the cached file on disk.
|
||||
line_one_based: Optional 1-based line to reveal.
|
||||
column_one_based: Optional 1-based column to reveal.
|
||||
remote_path: Optional remote path for UI labels.
|
||||
"""
|
||||
|
||||
local_cache_path: Path
|
||||
line_one_based: Optional[int] = None
|
||||
column_one_based: Optional[int] = None
|
||||
remote_path: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DiffProposalRef:
|
||||
"""Reference to a proposed patch with staleness against live files.
|
||||
|
||||
Attributes:
|
||||
proposal_id: Stable identifier for the proposal in local state.
|
||||
paths: Remote paths included in the proposal.
|
||||
source_snapshot_mtime_ns: Best-effort mtime when the proposal was built.
|
||||
current_source_mtime_ns: Observed mtime at review time; optional.
|
||||
"""
|
||||
|
||||
proposal_id: str
|
||||
paths: Tuple[str, ...]
|
||||
source_snapshot_mtime_ns: Optional[int] = None
|
||||
current_source_mtime_ns: Optional[int] = None
|
||||
|
||||
def is_stale(self) -> bool:
|
||||
"""Return True when the underlying file likely changed since generation.
|
||||
|
||||
Args:
|
||||
None.
|
||||
|
||||
Returns:
|
||||
Whether the proposal should be treated as stale.
|
||||
|
||||
Raises:
|
||||
None.
|
||||
"""
|
||||
snap = self.source_snapshot_mtime_ns
|
||||
cur = self.current_source_mtime_ns
|
||||
if snap is None or cur is None:
|
||||
return False
|
||||
return cur != snap
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DirectoryPaneDescriptor:
|
||||
"""Describe directory browsing beside editor content (right pane).
|
||||
|
||||
Attributes:
|
||||
pane_id: Stable pane identifier for layout code.
|
||||
root_remote_path: Remote directory root for browsing.
|
||||
root_local_cache_path: Local cache mirror root for the same tree.
|
||||
default_max_depth: Soft cap for shallow listing in the prototype.
|
||||
follow_symlinks: Whether symlink expansion is allowed (default False).
|
||||
"""
|
||||
|
||||
pane_id: str
|
||||
root_remote_path: str
|
||||
root_local_cache_path: Path
|
||||
default_max_depth: int = 2
|
||||
follow_symlinks: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AgentTimelineEntry:
|
||||
"""One chat-style or activity row in the center pane."""
|
||||
|
||||
entry_id: str
|
||||
timestamp_iso: str
|
||||
kind: TimelineEntryKind
|
||||
title: str
|
||||
body_summary: str
|
||||
action: Optional[StructuredActionSummary] = None
|
||||
jump: Optional[EditorJumpTarget] = None
|
||||
diff: Optional[DiffProposalRef] = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AgentSessionRow:
|
||||
"""One selectable session in the left pane derived from recent metadata."""
|
||||
|
||||
session_id: str
|
||||
host_alias: str
|
||||
remote_root: str
|
||||
cache_key: str
|
||||
last_connected_at: str
|
||||
display_title: str
|
||||
display_subtitle: str
|
||||
expected_cache_dir: Path
|
||||
availability: SessionAvailability
|
||||
disambiguation_hint: Optional[str] = None
|
||||
|
||||
|
||||
def _cache_dir(cache_root: Path, cache_key: str) -> Path:
|
||||
return cache_root / cache_key
|
||||
|
||||
|
||||
def _recency_stale_seconds(last_connected_iso: str, now_epoch_seconds: float) -> bool:
|
||||
"""Heuristic: disconnected sessions older than 7 days are 'stale metadata'."""
|
||||
from datetime import datetime
|
||||
|
||||
try:
|
||||
parsed = datetime.fromisoformat(last_connected_iso.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return True
|
||||
age = now_epoch_seconds - parsed.timestamp()
|
||||
return age > 7 * 24 * 3600
|
||||
|
||||
|
||||
def build_agent_session_rows(
|
||||
entries: Sequence[RecentWorkspace],
|
||||
cache_root: Path,
|
||||
*,
|
||||
now_epoch_seconds: float,
|
||||
live_session_ids: Optional[Mapping[str, bool]] = None,
|
||||
cache_origin_host_by_key: Optional[Mapping[str, str]] = None,
|
||||
current_host_name: str = "local",
|
||||
) -> Tuple[AgentSessionRow, ...]:
|
||||
"""Project recent workspaces into agent window session rows.
|
||||
|
||||
Session identity follows `cache_key` so the same host and remote root with
|
||||
different workspace profiles remain distinct rows when both appear in the
|
||||
supplied entry list.
|
||||
|
||||
Args:
|
||||
entries: Recent workspace entries, typically newest-first.
|
||||
cache_root: Resolved cache root (local or shared).
|
||||
now_epoch_seconds: Current time for staleness heuristics.
|
||||
live_session_ids: Optional map of cache_key -> connected flag.
|
||||
cache_origin_host_by_key: Optional map of cache_key -> host that wrote cache.
|
||||
current_host_name: Label for this machine when checking shared-cache origin.
|
||||
|
||||
Returns:
|
||||
Tuple of session rows aligned with disambiguation rules.
|
||||
|
||||
Raises:
|
||||
None.
|
||||
"""
|
||||
live: Dict[str, bool] = dict(live_session_ids or {})
|
||||
origins: Dict[str, str] = dict(cache_origin_host_by_key or {})
|
||||
rows: List[AgentSessionRow] = []
|
||||
seen_keys: Set[str] = set()
|
||||
|
||||
for entry in entries:
|
||||
if entry.cache_key in seen_keys:
|
||||
continue
|
||||
seen_keys.add(entry.cache_key)
|
||||
cache_path = _cache_dir(cache_root, entry.cache_key)
|
||||
cache_exists = cache_path.is_dir()
|
||||
origin = origins.get(entry.cache_key)
|
||||
foreign = origin is not None and origin != current_host_name
|
||||
|
||||
if live.get(entry.cache_key):
|
||||
availability = SessionAvailability.CONNECTED
|
||||
elif not cache_exists:
|
||||
availability = SessionAvailability.CACHE_MISSING
|
||||
elif foreign:
|
||||
availability = SessionAvailability.FOREIGN_SHARED_CACHE
|
||||
elif _recency_stale_seconds(entry.last_connected_at, now_epoch_seconds):
|
||||
availability = SessionAvailability.STALE_METADATA
|
||||
else:
|
||||
availability = SessionAvailability.OFFLINE_ASSUMED
|
||||
|
||||
title = "{}: {}".format(entry.host_alias, entry.remote_root)
|
||||
subtitle = "cache {}…".format(entry.cache_key[:8])
|
||||
disambiguation: Optional[str] = None
|
||||
same_root_prior = [
|
||||
r
|
||||
for r in rows
|
||||
if r.host_alias == entry.host_alias and r.remote_root == entry.remote_root
|
||||
]
|
||||
if same_root_prior:
|
||||
disambiguation = "Different workspace profile or cache identity"
|
||||
subtitle = "{} ({})".format(subtitle, entry.cache_key[:12])
|
||||
|
||||
rows.append(
|
||||
AgentSessionRow(
|
||||
session_id=entry.cache_key,
|
||||
host_alias=entry.host_alias,
|
||||
remote_root=entry.remote_root,
|
||||
cache_key=entry.cache_key,
|
||||
last_connected_at=entry.last_connected_at,
|
||||
display_title=title,
|
||||
display_subtitle=subtitle,
|
||||
expected_cache_dir=cache_path,
|
||||
availability=availability,
|
||||
disambiguation_hint=disambiguation,
|
||||
)
|
||||
)
|
||||
return tuple(rows)
|
||||
|
||||
|
||||
def trim_timeline_for_long_history(
|
||||
entries: Sequence[AgentTimelineEntry],
|
||||
*,
|
||||
max_entries: int,
|
||||
max_body_chars: int,
|
||||
) -> Tuple[AgentTimelineEntry, ...]:
|
||||
"""Keep the newest slice and clamp oversized bodies for the activity pane.
|
||||
|
||||
Args:
|
||||
entries: Timeline entries oldest-to-newest or arbitrary order.
|
||||
max_entries: Maximum number of entries to retain (newest last).
|
||||
max_body_chars: Maximum characters kept in each body summary.
|
||||
|
||||
Returns:
|
||||
Trimmed tuple of entries.
|
||||
|
||||
Raises:
|
||||
None.
|
||||
"""
|
||||
ordered = list(entries)
|
||||
if len(ordered) > max_entries:
|
||||
ordered = ordered[-max_entries:]
|
||||
trimmed: List[AgentTimelineEntry] = []
|
||||
for item in ordered:
|
||||
body = item.body_summary
|
||||
if len(body) > max_body_chars:
|
||||
body = body[: max_body_chars - 1] + "…"
|
||||
if body == item.body_summary:
|
||||
trimmed.append(item)
|
||||
else:
|
||||
trimmed.append(
|
||||
AgentTimelineEntry(
|
||||
entry_id=item.entry_id,
|
||||
timestamp_iso=item.timestamp_iso,
|
||||
kind=item.kind,
|
||||
title=item.title,
|
||||
body_summary=body,
|
||||
action=item.action,
|
||||
jump=item.jump,
|
||||
diff=item.diff,
|
||||
)
|
||||
)
|
||||
return tuple(trimmed)
|
||||
|
||||
|
||||
def timeline_placeholder_for_missing_cache(
|
||||
session_row: AgentSessionRow,
|
||||
) -> AgentTimelineEntry:
|
||||
"""Return a single system entry when no cache exists yet for the session.
|
||||
|
||||
Args:
|
||||
session_row: Session metadata for the selection.
|
||||
|
||||
Returns:
|
||||
One timeline entry explaining the missing cache state.
|
||||
|
||||
Raises:
|
||||
None.
|
||||
"""
|
||||
return AgentTimelineEntry(
|
||||
entry_id="missing-cache",
|
||||
timestamp_iso=session_row.last_connected_at,
|
||||
kind=TimelineEntryKind.SYSTEM_EVENT,
|
||||
title="Cache not materialized",
|
||||
body_summary=(
|
||||
"This workspace has no local cache directory yet. "
|
||||
"Reconnect or open the project to materialize cache before browsing files."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def timeline_placeholder_for_foreign_shared_cache(
|
||||
session_row: AgentSessionRow,
|
||||
*,
|
||||
origin_host: str,
|
||||
) -> AgentTimelineEntry:
|
||||
"""Warn when shared cache may have been written on another workstation.
|
||||
|
||||
Args:
|
||||
session_row: Session row flagged as foreign shared cache.
|
||||
origin_host: Host label stored alongside the shared cache root.
|
||||
|
||||
Returns:
|
||||
System timeline entry describing the shared-cache scenario.
|
||||
|
||||
Raises:
|
||||
None.
|
||||
"""
|
||||
return AgentTimelineEntry(
|
||||
entry_id="foreign-shared-cache",
|
||||
timestamp_iso=session_row.last_connected_at,
|
||||
kind=TimelineEntryKind.SYSTEM_EVENT,
|
||||
title="Shared cache from another machine",
|
||||
body_summary=(
|
||||
"Cache directory appears to originate from `{}`. ".format(origin_host)
|
||||
+ "Review concurrent edits and prefer summary-first diagnostics before "
|
||||
"assuming local mirror freshness."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def example_structured_helper_summary() -> StructuredActionSummary:
|
||||
"""Return a representative helper summary for documentation and tests.
|
||||
|
||||
Args:
|
||||
None.
|
||||
|
||||
Returns:
|
||||
Sample structured summary.
|
||||
|
||||
Raises:
|
||||
None.
|
||||
"""
|
||||
return StructuredActionSummary(
|
||||
verb="Read remote file",
|
||||
target_remote_path="/srv/app/README.md",
|
||||
exit_code=0,
|
||||
duration_ms=42,
|
||||
stderr_preview=None,
|
||||
stdout_line_count=0,
|
||||
notes="Remote metadata matched cache mapping.",
|
||||
)
|
||||
|
||||
|
||||
def collect_jump_targets_from_timeline(
|
||||
entries: Iterable[AgentTimelineEntry],
|
||||
) -> Tuple[EditorJumpTarget, ...]:
|
||||
"""Extract explicit editor jump targets linked from timeline rows.
|
||||
|
||||
Args:
|
||||
entries: Timeline entries possibly containing jump targets.
|
||||
|
||||
Returns:
|
||||
Tuple of unique jump targets in encounter order.
|
||||
|
||||
Raises:
|
||||
None.
|
||||
"""
|
||||
out: List[EditorJumpTarget] = []
|
||||
seen: Set[Tuple[str, Optional[int], Optional[int]]] = set()
|
||||
for item in entries:
|
||||
if item.jump is None:
|
||||
continue
|
||||
key = (
|
||||
str(item.jump.local_cache_path),
|
||||
item.jump.line_one_based,
|
||||
item.jump.column_one_based,
|
||||
)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
out.append(item.jump)
|
||||
return tuple(out)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AgentWindowViewState:
|
||||
"""Bundle layout, session list, and center-pane content for one snapshot.
|
||||
|
||||
Attributes:
|
||||
layout: Region ordering and presentation flags.
|
||||
session_rows: Left-pane rows from recent metadata.
|
||||
selected_session_id: Currently focused `cache_key`, if any.
|
||||
timeline_entries: Center-pane activity stream after trimming.
|
||||
directory_pane: Optional right-pane directory descriptor.
|
||||
"""
|
||||
|
||||
layout: AgentWindowLayoutSpec
|
||||
session_rows: Tuple[AgentSessionRow, ...]
|
||||
selected_session_id: Optional[str]
|
||||
timeline_entries: Tuple[AgentTimelineEntry, ...]
|
||||
directory_pane: Optional[DirectoryPaneDescriptor] = None
|
||||
|
||||
|
||||
def build_agent_window_view_state(
|
||||
entries: Sequence[RecentWorkspace],
|
||||
cache_root: Path,
|
||||
*,
|
||||
now_epoch_seconds: float,
|
||||
selected_session_id: Optional[str],
|
||||
raw_timeline: Sequence[AgentTimelineEntry],
|
||||
live_session_ids: Optional[Mapping[str, bool]] = None,
|
||||
cache_origin_host_by_key: Optional[Mapping[str, str]] = None,
|
||||
current_host_name: str = "local",
|
||||
timeline_max_entries: int = 200,
|
||||
timeline_max_body_chars: int = 4000,
|
||||
layout: AgentWindowLayoutSpec = DEFAULT_AGENT_WINDOW_LAYOUT,
|
||||
) -> AgentWindowViewState:
|
||||
"""Assemble agent window snapshot state for tests and future UI wiring.
|
||||
|
||||
When the selected session has no cache directory, the timeline is replaced
|
||||
with a placeholder explaining reconnect is required. Foreign shared-cache
|
||||
sessions prepend a warning entry before trimmed timeline content.
|
||||
|
||||
Args:
|
||||
entries: Recent workspace entries backing the session list.
|
||||
cache_root: Active cache root for path resolution.
|
||||
now_epoch_seconds: Wall clock for staleness heuristics.
|
||||
selected_session_id: Which session row is focused.
|
||||
raw_timeline: Unbounded timeline for the selection.
|
||||
live_session_ids: Optional connectivity map keyed by `cache_key`.
|
||||
cache_origin_host_by_key: Optional writer host labels for shared cache.
|
||||
current_host_name: This machine label for foreign-cache detection.
|
||||
timeline_max_entries: Cap for very long histories.
|
||||
timeline_max_body_chars: Per-entry body clamp.
|
||||
layout: Layout spec; defaults to Issue G three-pane ordering.
|
||||
|
||||
Returns:
|
||||
Frozen view state suitable for rendering.
|
||||
|
||||
Raises:
|
||||
None.
|
||||
"""
|
||||
rows = build_agent_session_rows(
|
||||
entries,
|
||||
cache_root,
|
||||
now_epoch_seconds=now_epoch_seconds,
|
||||
live_session_ids=live_session_ids,
|
||||
cache_origin_host_by_key=cache_origin_host_by_key,
|
||||
current_host_name=current_host_name,
|
||||
)
|
||||
row_by_id = {r.session_id: r for r in rows}
|
||||
selected = row_by_id.get(selected_session_id) if selected_session_id else None
|
||||
|
||||
timeline = trim_timeline_for_long_history(
|
||||
raw_timeline,
|
||||
max_entries=timeline_max_entries,
|
||||
max_body_chars=timeline_max_body_chars,
|
||||
)
|
||||
|
||||
missing = (
|
||||
selected is not None
|
||||
and selected.availability == SessionAvailability.CACHE_MISSING
|
||||
)
|
||||
foreign = (
|
||||
selected is not None
|
||||
and selected.availability == SessionAvailability.FOREIGN_SHARED_CACHE
|
||||
)
|
||||
if missing:
|
||||
timeline = (timeline_placeholder_for_missing_cache(selected),)
|
||||
elif foreign:
|
||||
origins_map = cache_origin_host_by_key or {}
|
||||
origin = origins_map.get(selected.cache_key, "unknown-host")
|
||||
warn = timeline_placeholder_for_foreign_shared_cache(
|
||||
selected,
|
||||
origin_host=origin,
|
||||
)
|
||||
timeline = (warn, *timeline)
|
||||
|
||||
directory: Optional[DirectoryPaneDescriptor] = None
|
||||
has_cache = (
|
||||
selected is not None
|
||||
and selected.availability != SessionAvailability.CACHE_MISSING
|
||||
)
|
||||
if has_cache:
|
||||
directory = DirectoryPaneDescriptor(
|
||||
pane_id="tree-{}".format(selected.cache_key[:8]),
|
||||
root_remote_path=selected.remote_root,
|
||||
root_local_cache_path=selected.expected_cache_dir,
|
||||
)
|
||||
|
||||
return AgentWindowViewState(
|
||||
layout=layout,
|
||||
session_rows=rows,
|
||||
selected_session_id=selected_session_id,
|
||||
timeline_entries=timeline,
|
||||
directory_pane=directory,
|
||||
)
|
||||
|
||||
|
||||
def load_agent_window_view_state_from_recent_store(
|
||||
store: RecentWorkspaceStore,
|
||||
cache_root: Path,
|
||||
*,
|
||||
now_epoch_seconds: float,
|
||||
selected_session_id: Optional[str],
|
||||
raw_timeline: Sequence[AgentTimelineEntry],
|
||||
live_session_ids: Optional[Mapping[str, bool]] = None,
|
||||
cache_origin_host_by_key: Optional[Mapping[str, str]] = None,
|
||||
current_host_name: str = "local",
|
||||
timeline_max_entries: int = 200,
|
||||
timeline_max_body_chars: int = 4000,
|
||||
layout: AgentWindowLayoutSpec = DEFAULT_AGENT_WINDOW_LAYOUT,
|
||||
) -> AgentWindowViewState:
|
||||
"""Build view state from persisted local-only recent workspace metadata.
|
||||
|
||||
Args:
|
||||
store: Recent workspace JSON store.
|
||||
cache_root: Resolved cache root for session rows.
|
||||
now_epoch_seconds: Wall clock for staleness heuristics.
|
||||
selected_session_id: Which session row is focused.
|
||||
raw_timeline: Unbounded timeline for the selection.
|
||||
live_session_ids: Optional connectivity map keyed by `cache_key`.
|
||||
cache_origin_host_by_key: Optional writer host labels for shared cache.
|
||||
current_host_name: This machine label for foreign-cache detection.
|
||||
timeline_max_entries: Cap for very long histories.
|
||||
timeline_max_body_chars: Per-entry body clamp.
|
||||
layout: Layout spec; defaults to Issue G three-pane ordering.
|
||||
|
||||
Returns:
|
||||
Assembled ``AgentWindowViewState``.
|
||||
|
||||
Raises:
|
||||
OSError: If the store cannot be read.
|
||||
json.JSONDecodeError: If stored JSON is invalid.
|
||||
TypeError: If stored entries do not match the schema.
|
||||
"""
|
||||
index = store.load_index()
|
||||
return build_agent_window_view_state(
|
||||
index.entries,
|
||||
cache_root,
|
||||
now_epoch_seconds=now_epoch_seconds,
|
||||
selected_session_id=selected_session_id,
|
||||
raw_timeline=raw_timeline,
|
||||
live_session_ids=live_session_ids,
|
||||
cache_origin_host_by_key=cache_origin_host_by_key,
|
||||
current_host_name=current_host_name,
|
||||
timeline_max_entries=timeline_max_entries,
|
||||
timeline_max_body_chars=timeline_max_body_chars,
|
||||
layout=layout,
|
||||
)
|
||||
@@ -1,257 +0,0 @@
|
||||
"""Three-group window layout helpers for the agent-via-tmux track.
|
||||
|
||||
Track D of ``planning/AGENT_TMUX_LAYOUT.md`` (§D2) splits a Sublime window
|
||||
into three vertical groups:
|
||||
|
||||
- group 0: editor (local cache files / diff previews);
|
||||
- group 1: Terminus pane attached to the tmux agent session;
|
||||
- group 2: the agent-switcher view (a clickable pair list).
|
||||
|
||||
This module is intentionally small — pure geometry plus two Window
|
||||
commands — and never reaches for any protocol/IO layer. The integrator
|
||||
wires Terminus spawn + pair data on top; here we only compute the
|
||||
``set_layout`` payload and persist the current layout id on the window
|
||||
project data so a reload restores the same shape.
|
||||
|
||||
The Sublime API is imported lazily via the ``try: import sublime_plugin``
|
||||
pattern so ``pytest sublime/tests/`` keeps collecting without a
|
||||
``sublime`` stub installed (see :mod:`sessions.terminal_link_click` for
|
||||
the same idiom).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
try:
|
||||
import sublime_plugin # type: ignore
|
||||
|
||||
import sublime # type: ignore
|
||||
except ImportError: # pragma: no cover - unit tests import without Sublime
|
||||
sublime = None # type: ignore[assignment]
|
||||
sublime_plugin = None # type: ignore[assignment]
|
||||
|
||||
|
||||
# Project-data key used to remember the last-applied layout for this
|
||||
# window. The integrator reads this during activation to re-apply the
|
||||
# same layout without blinking between shapes.
|
||||
LAYOUT_STATE_KEY = "sessions_agent_layout_id"
|
||||
|
||||
LAYOUT_ID_THREE_GROUP = "three_group"
|
||||
LAYOUT_ID_TWO_GROUP = "two_group"
|
||||
LAYOUT_ID_OTHER = "other"
|
||||
|
||||
|
||||
def build_three_group_layout(
|
||||
editor_frac: float = 0.40,
|
||||
terminus_frac: float = 0.80,
|
||||
) -> Dict[str, Any]:
|
||||
"""Return a ``set_layout`` dict for the editor/terminus/switcher split.
|
||||
|
||||
``editor_frac`` is the right edge of group 0; ``terminus_frac`` is the
|
||||
right edge of group 1. Both fractions must satisfy
|
||||
``0 < editor_frac < terminus_frac < 1``. Out-of-order values are
|
||||
clamped to a sensible monotonic sequence so callers can pass
|
||||
user-editable settings without crashing the window.
|
||||
"""
|
||||
editor_frac, terminus_frac = _sanitize_three_group_fracs(editor_frac, terminus_frac)
|
||||
return {
|
||||
"cols": [0.0, editor_frac, terminus_frac, 1.0],
|
||||
"rows": [0.0, 1.0],
|
||||
"cells": [[0, 0, 1, 1], [1, 0, 2, 1], [2, 0, 3, 1]],
|
||||
}
|
||||
|
||||
|
||||
def build_two_group_layout(editor_frac: float = 0.50) -> Dict[str, Any]:
|
||||
"""Return the ``set_layout`` dict used after collapsing the switcher group.
|
||||
|
||||
The editor keeps group 0; Terminus widens into group 1 to fill the
|
||||
previously-switcher column. ``editor_frac`` stays clamped to a
|
||||
narrow usable range — a layout with a 0-wide group traps the user.
|
||||
"""
|
||||
editor_frac = _clamp(editor_frac, 0.05, 0.95)
|
||||
return {
|
||||
"cols": [0.0, editor_frac, 1.0],
|
||||
"rows": [0.0, 1.0],
|
||||
"cells": [[0, 0, 1, 1], [1, 0, 2, 1]],
|
||||
}
|
||||
|
||||
|
||||
def current_layout_id(window: object) -> str:
|
||||
"""Return ``"three_group"`` / ``"two_group"`` / ``"other"`` for ``window``.
|
||||
|
||||
Used by the integrator to decide whether a layout change is needed on
|
||||
activation. We compare structurally against the shapes produced by
|
||||
:func:`build_three_group_layout` / :func:`build_two_group_layout`
|
||||
ignoring the exact ``cols`` fractions — only the cell topology is
|
||||
load-bearing for identity.
|
||||
"""
|
||||
get_layout = getattr(window, "get_layout", None)
|
||||
if not callable(get_layout):
|
||||
return LAYOUT_ID_OTHER
|
||||
try:
|
||||
layout = get_layout()
|
||||
except Exception: # pragma: no cover - defensive
|
||||
return LAYOUT_ID_OTHER
|
||||
if not isinstance(layout, dict):
|
||||
return LAYOUT_ID_OTHER
|
||||
cells = layout.get("cells")
|
||||
rows = layout.get("rows")
|
||||
if not isinstance(cells, list) or not isinstance(rows, list):
|
||||
return LAYOUT_ID_OTHER
|
||||
# A "single row" layout (rows == [0.0, 1.0]) is the only shape we
|
||||
# produce — anything else is user-configured or from another plugin.
|
||||
if len(rows) != 2:
|
||||
return LAYOUT_ID_OTHER
|
||||
normalized = [_normalize_cell(cell) for cell in cells]
|
||||
if None in normalized:
|
||||
return LAYOUT_ID_OTHER
|
||||
if normalized == [(0, 0, 1, 1), (1, 0, 2, 1), (2, 0, 3, 1)]:
|
||||
return LAYOUT_ID_THREE_GROUP
|
||||
if normalized == [(0, 0, 1, 1), (1, 0, 2, 1)]:
|
||||
return LAYOUT_ID_TWO_GROUP
|
||||
return LAYOUT_ID_OTHER
|
||||
|
||||
|
||||
def read_stored_layout_id(window: object) -> Optional[str]:
|
||||
"""Return the previously-persisted layout id for ``window`` or ``None``."""
|
||||
project_data = _get_project_data(window)
|
||||
if not isinstance(project_data, dict):
|
||||
return None
|
||||
settings = project_data.get("settings")
|
||||
if not isinstance(settings, dict):
|
||||
return None
|
||||
value = settings.get(LAYOUT_STATE_KEY)
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def write_stored_layout_id(window: object, layout_id: str) -> None:
|
||||
"""Persist ``layout_id`` under ``settings.sessions_agent_layout_id`` on ``window``.
|
||||
|
||||
A missing ``project_data`` or ``set_project_data`` is a silent no-op;
|
||||
the integrator may call this on bare windows that have no project.
|
||||
"""
|
||||
set_project_data = getattr(window, "set_project_data", None)
|
||||
if not callable(set_project_data):
|
||||
return
|
||||
project_data = _get_project_data(window)
|
||||
if not isinstance(project_data, dict):
|
||||
project_data = {}
|
||||
settings = project_data.get("settings")
|
||||
if not isinstance(settings, dict):
|
||||
settings = {}
|
||||
updated_settings = dict(settings)
|
||||
updated_settings[LAYOUT_STATE_KEY] = layout_id
|
||||
updated = dict(project_data)
|
||||
updated["settings"] = updated_settings
|
||||
set_project_data(updated)
|
||||
|
||||
|
||||
def _get_project_data(window: object) -> Optional[Dict[str, Any]]:
|
||||
project_data_fn = getattr(window, "project_data", None)
|
||||
if not callable(project_data_fn):
|
||||
return None
|
||||
try:
|
||||
data = project_data_fn()
|
||||
except Exception: # pragma: no cover - defensive
|
||||
return None
|
||||
if isinstance(data, dict):
|
||||
return data
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_cell(cell: Any) -> Optional[tuple]:
|
||||
if not isinstance(cell, (list, tuple)):
|
||||
return None
|
||||
if len(cell) != 4:
|
||||
return None
|
||||
try:
|
||||
return (int(cell[0]), int(cell[1]), int(cell[2]), int(cell[3]))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _clamp(value: float, low: float, high: float) -> float:
|
||||
if value < low:
|
||||
return low
|
||||
if value > high:
|
||||
return high
|
||||
return value
|
||||
|
||||
|
||||
def _sanitize_three_group_fracs(
|
||||
editor_frac: float, terminus_frac: float
|
||||
) -> List[float]:
|
||||
"""Coerce the two fractions into a strictly-increasing pair inside ``(0, 1)``.
|
||||
|
||||
Returns ``[editor, terminus]``. Callers with inverted / equal inputs
|
||||
get a deterministic fallback rather than a corrupted layout.
|
||||
"""
|
||||
editor = _clamp(editor_frac, 0.05, 0.95)
|
||||
terminus = _clamp(terminus_frac, 0.05, 0.95)
|
||||
if terminus <= editor:
|
||||
# Nudge ``terminus`` to at least ``editor + 0.1``, still inside
|
||||
# the usable range. If ``editor`` is already near the right
|
||||
# boundary, pull it back so both groups stay visible.
|
||||
if editor > 0.85:
|
||||
editor = 0.85
|
||||
terminus = min(0.95, editor + 0.1)
|
||||
return [editor, terminus]
|
||||
|
||||
|
||||
_WindowCommandBase = (
|
||||
sublime_plugin.WindowCommand if sublime_plugin is not None else object
|
||||
)
|
||||
|
||||
|
||||
class SessionsAgentLayoutCommand(_WindowCommandBase): # type: ignore[misc]
|
||||
"""Split the active window into three groups (editor | terminus | switcher)."""
|
||||
|
||||
def run(
|
||||
self,
|
||||
editor_frac: float = 0.40,
|
||||
terminus_frac: float = 0.80,
|
||||
) -> None:
|
||||
"""Apply the three-group layout and persist the id on the project."""
|
||||
window = getattr(self, "window", None)
|
||||
if window is None:
|
||||
return
|
||||
set_layout = getattr(window, "set_layout", None)
|
||||
if not callable(set_layout):
|
||||
return
|
||||
layout = build_three_group_layout(editor_frac, terminus_frac)
|
||||
set_layout(layout)
|
||||
write_stored_layout_id(window, LAYOUT_ID_THREE_GROUP)
|
||||
|
||||
|
||||
class SessionsAgentLayoutCollapseSwitcherCommand(_WindowCommandBase): # type: ignore[misc]
|
||||
"""Hide the switcher group by extending Terminus to the right edge."""
|
||||
|
||||
def run(self, editor_frac: float = 0.50) -> None:
|
||||
"""Apply the two-group layout and persist the id on the project."""
|
||||
window = getattr(self, "window", None)
|
||||
if window is None:
|
||||
return
|
||||
set_layout = getattr(window, "set_layout", None)
|
||||
if not callable(set_layout):
|
||||
return
|
||||
layout = build_two_group_layout(editor_frac)
|
||||
set_layout(layout)
|
||||
write_stored_layout_id(window, LAYOUT_ID_TWO_GROUP)
|
||||
|
||||
|
||||
__all__ = (
|
||||
"LAYOUT_ID_OTHER",
|
||||
"LAYOUT_ID_THREE_GROUP",
|
||||
"LAYOUT_ID_TWO_GROUP",
|
||||
"LAYOUT_STATE_KEY",
|
||||
"SessionsAgentLayoutCollapseSwitcherCommand",
|
||||
"SessionsAgentLayoutCommand",
|
||||
"build_three_group_layout",
|
||||
"build_two_group_layout",
|
||||
"current_layout_id",
|
||||
"read_stored_layout_id",
|
||||
"write_stored_layout_id",
|
||||
)
|
||||
@@ -6,7 +6,6 @@ import datetime as _datetime
|
||||
import hashlib
|
||||
import importlib
|
||||
import json
|
||||
import logging
|
||||
import shlex
|
||||
import shutil
|
||||
import sys
|
||||
@@ -29,17 +28,6 @@ from typing import (
|
||||
)
|
||||
|
||||
from . import _rust_ffi
|
||||
from .agent_remote_payload import (
|
||||
AgentEditorPayload,
|
||||
parse_agent_editor_envelope_from_stdout,
|
||||
)
|
||||
from .agent_switcher_view import (
|
||||
SWITCHER_VIEW_SETTING_KEY,
|
||||
AgentPairSummary,
|
||||
render_switcher_body,
|
||||
)
|
||||
from .agent_tmux import AgentTmuxBroker, AgentTmuxError, TmuxAgentSession
|
||||
from .agent_window_layout import build_three_group_layout
|
||||
from .connect_preflight import (
|
||||
ConnectPreflightError,
|
||||
ConnectStatus,
|
||||
@@ -158,38 +146,15 @@ from .ssh_runner import (
|
||||
ssh_prompt_callback,
|
||||
)
|
||||
from .ssh_tool_runtime import execute_remote_tool_request
|
||||
|
||||
# Re-exported for the legacy `commands.X` patch surface (tests + plugin.py
|
||||
# reach in via that namespace). The `from . import commands as _root` lookup
|
||||
# inside command submodules resolves through these bindings, so a
|
||||
# `monkeypatch.setattr(commands, "list_terminal_sessions", ...)` in tests
|
||||
# still intercepts the real call site.
|
||||
from .terminal_tmux_session import ( # noqa: F401
|
||||
SESSION_NAME_PREFIX,
|
||||
TerminalTmuxSessionError,
|
||||
build_remote_tmux_invocation,
|
||||
kill_terminal_session,
|
||||
list_all_remote_tmux_sessions,
|
||||
list_terminal_sessions,
|
||||
next_terminal_session_name,
|
||||
probe_tmux_available,
|
||||
session_name_for_host,
|
||||
)
|
||||
from .workspace_state import (
|
||||
PROJECT_SETTINGS_KEY,
|
||||
AgentPair,
|
||||
WorkspaceBootstrapPlan,
|
||||
WorkspaceIdentity,
|
||||
active_agent_pair_id,
|
||||
clear_deferred_directory,
|
||||
connect_workspace,
|
||||
default_local_paths,
|
||||
deferred_directories_for,
|
||||
forget_agent_pair,
|
||||
list_agent_pairs,
|
||||
lookup_agent_pair,
|
||||
record_deferred_directories,
|
||||
register_agent_pair,
|
||||
)
|
||||
|
||||
_REMOTE_DIRECTORY_EXPLORER_LAYOUT = {
|
||||
@@ -2051,17 +2016,6 @@ class SessionsOnDemandFetchListener(sublime_plugin.EventListener):
|
||||
# Case 1: path is under workspace cache but file missing on disk
|
||||
remote = mapper.remote_path_for_local_cache_file(local_path)
|
||||
if remote is not None:
|
||||
# .ipynb gets diverted to the remote Jupyter Lab server instead
|
||||
# of being opened as raw JSON in a Sublime buffer. The browser
|
||||
# opens via an SSH tunnel to the remote server.
|
||||
if remote.endswith(".ipynb"):
|
||||
run_cmd = getattr(window, "run_command", None)
|
||||
if callable(run_cmd):
|
||||
run_cmd(
|
||||
"sessions_open_remote_jupyter",
|
||||
{"notebook_path": remote},
|
||||
)
|
||||
return ("noop", {})
|
||||
try:
|
||||
if local_path.is_file():
|
||||
return None
|
||||
@@ -2072,17 +2026,6 @@ class SessionsOnDemandFetchListener(sublime_plugin.EventListener):
|
||||
|
||||
# Case 2: path looks like a remote absolute path (POSIX)
|
||||
if file_path.startswith("/") and not local_path.is_file():
|
||||
if file_path.endswith(".ipynb"):
|
||||
# Route remote .ipynb paths through Jupyter Lab. The server
|
||||
# only serves files under its root_dir; the URL builder
|
||||
# downgrades to /lab (no tree) if the path lies outside.
|
||||
run_cmd = getattr(window, "run_command", None)
|
||||
if callable(run_cmd):
|
||||
run_cmd(
|
||||
"sessions_open_remote_jupyter",
|
||||
{"notebook_path": file_path},
|
||||
)
|
||||
return ("noop", {})
|
||||
extern_local = mapper.local_path_for_external_remote_file(file_path)
|
||||
try:
|
||||
if extern_local.is_file() and extern_local.stat().st_size > 0:
|
||||
@@ -3118,154 +3061,6 @@ def _remote_extension_exec_failure_detail(
|
||||
)
|
||||
|
||||
|
||||
class SessionsPreviewRemoteAgentPayloadCommand(sublime_plugin.WindowCommand):
|
||||
"""Fetch a remote agent preview envelope and render it in an output panel."""
|
||||
|
||||
def is_visible(self) -> bool:
|
||||
"""Hide from the main palette unless the dev-commands toggle is on.
|
||||
|
||||
This is a developer / debugging command — it reads an arbitrary
|
||||
remote command's stdout and renders the JSON payload in a scratch
|
||||
view. Useful when debugging the agent envelope round-trip;
|
||||
distracting clutter for non-maintainer users. Gate behind
|
||||
``sessions_show_dev_commands`` (default ``false``); maintainers
|
||||
flip the flag in their User-level Sessions.sublime-settings.
|
||||
"""
|
||||
load_settings = getattr(sublime, "load_settings", None)
|
||||
if not callable(load_settings):
|
||||
return False
|
||||
try:
|
||||
stored = load_settings("Sessions.sublime-settings")
|
||||
except Exception:
|
||||
return False
|
||||
getter = getattr(stored, "get", None)
|
||||
if not callable(getter):
|
||||
return False
|
||||
return bool(getter("sessions_show_dev_commands", False))
|
||||
|
||||
def run(
|
||||
self,
|
||||
remote_command: str = "",
|
||||
timeout_s: float = 30.0,
|
||||
) -> None:
|
||||
"""Execute ``remote_command`` over SSH and render the validated payload."""
|
||||
settings = SessionsSettings()
|
||||
context = _workspace_context(self.window, settings)
|
||||
if context is None:
|
||||
return
|
||||
command = (remote_command or "").strip()
|
||||
if not command:
|
||||
default_command = "cat /tmp/sessions-agent-preview.json"
|
||||
self.window.show_input_panel(
|
||||
"Remote agent payload command:",
|
||||
default_command,
|
||||
lambda value: _run_preview_remote_agent_payload(
|
||||
self.window,
|
||||
context,
|
||||
value,
|
||||
timeout_s=float(timeout_s),
|
||||
),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
return
|
||||
_run_preview_remote_agent_payload(
|
||||
self.window,
|
||||
context,
|
||||
command,
|
||||
timeout_s=float(timeout_s),
|
||||
)
|
||||
|
||||
|
||||
def _run_preview_remote_agent_payload(
|
||||
window: object,
|
||||
context: "_WorkspaceContext",
|
||||
remote_command: str,
|
||||
*,
|
||||
timeout_s: float,
|
||||
) -> None:
|
||||
command = (remote_command or "").strip()
|
||||
if not command:
|
||||
_status_message("Remote agent preview command must not be empty.")
|
||||
return
|
||||
_status_message("Fetching remote agent preview payload...")
|
||||
_run_in_background(
|
||||
_preview_remote_agent_payload_async,
|
||||
window,
|
||||
context,
|
||||
command,
|
||||
timeout_s,
|
||||
)
|
||||
|
||||
|
||||
def _preview_remote_agent_payload_async(
|
||||
window: object,
|
||||
context: "_WorkspaceContext",
|
||||
remote_command: str,
|
||||
timeout_s: float,
|
||||
) -> None:
|
||||
try:
|
||||
with ssh_prompt_callback(lambda prompt: _prompt_for_ssh_secret(window, prompt)):
|
||||
result = run_ssh_remote_command(
|
||||
context.recent_entry.host_alias,
|
||||
("sh", "-lc", remote_command),
|
||||
timeout_s=timeout_s,
|
||||
)
|
||||
except SessionHelperStartError as error:
|
||||
detail = error.detail
|
||||
_set_timeout(
|
||||
lambda: _emit_status(ConnectStatus(kind="disconnected", detail=detail))
|
||||
)
|
||||
return
|
||||
if result.returncode != 0:
|
||||
detail = format_ssh_transport_error(
|
||||
result,
|
||||
"Remote agent preview command failed.",
|
||||
)
|
||||
_set_timeout(lambda: _emit_status(ConnectStatus(kind="warning", detail=detail)))
|
||||
return
|
||||
payload, envelope_error = parse_agent_editor_envelope_from_stdout(result.stdout)
|
||||
if payload is None:
|
||||
detail = envelope_error or (
|
||||
"Remote agent preview payload was invalid; expected "
|
||||
"`sessions.agent_editor_preview` schema v1."
|
||||
)
|
||||
err_text = "Sessions: remote agent preview failed\n\n" + detail + "\n"
|
||||
_set_timeout(
|
||||
lambda err=err_text, d=detail: (
|
||||
_show_output_panel(window, "sessions_remote_agent_payload", err),
|
||||
_emit_status(ConnectStatus(kind="warning", detail=d)),
|
||||
)
|
||||
)
|
||||
return
|
||||
_set_timeout(lambda: _present_remote_agent_payload(window, payload))
|
||||
|
||||
|
||||
def _parse_agent_payload_from_stdout(stdout: str) -> Optional[AgentEditorPayload]:
|
||||
"""Parse stdout for tests; prefer ``parse_agent_editor_envelope_from_stdout``."""
|
||||
payload, _err = parse_agent_editor_envelope_from_stdout(stdout)
|
||||
return payload
|
||||
|
||||
|
||||
def _present_remote_agent_payload(window: object, payload: AgentEditorPayload) -> None:
|
||||
lines = [
|
||||
"Title",
|
||||
payload.title,
|
||||
"",
|
||||
"Remote Path",
|
||||
payload.target_remote_path or "(not provided)",
|
||||
"",
|
||||
"Unified Diff",
|
||||
payload.unified_diff,
|
||||
]
|
||||
_show_output_panel(
|
||||
window,
|
||||
"sessions_remote_agent_payload",
|
||||
"\n".join(lines).strip() + "\n",
|
||||
)
|
||||
_emit_status(ConnectStatus(kind="ready", detail="Remote agent preview rendered."))
|
||||
|
||||
|
||||
def _connect_selected_workspace(
|
||||
window: object,
|
||||
settings: SessionsSettings,
|
||||
@@ -6593,428 +6388,7 @@ def sessions_plugin_shutdown() -> None:
|
||||
_BRIDGE_HOST_WINDOW_IDS.clear()
|
||||
clear_bridge_handshake_listeners()
|
||||
shutdown_all_persistent_bridges()
|
||||
_jupyter_session_manager().stop_all()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Remote agent sessions (tmux-backed) — Track D integrator pass.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_AGENT_TMUX_BROKER: Optional[AgentTmuxBroker] = None
|
||||
_AGENT_SWITCHER_VIEW_BY_WINDOW: Dict[int, int] = {}
|
||||
|
||||
|
||||
def _agent_tmux_broker() -> AgentTmuxBroker:
|
||||
"""Return the process-global tmux broker (lazy-init)."""
|
||||
global _AGENT_TMUX_BROKER
|
||||
if _AGENT_TMUX_BROKER is None:
|
||||
_AGENT_TMUX_BROKER = AgentTmuxBroker()
|
||||
return _AGENT_TMUX_BROKER
|
||||
|
||||
|
||||
def _installable_agent_entries() -> List[Tuple[str, str, Tuple[str, ...]]]:
|
||||
"""Return ``(agent_id, display_label, agent_cmd)`` rows for every
|
||||
``kind="agent"`` catalog entry except the ``tmux`` prerequisite.
|
||||
|
||||
The ``agent_cmd`` is a simple argv — typically just the CLI binary name
|
||||
(``claude``, ``codex``). tmux resolves it against the remote ``PATH``.
|
||||
"""
|
||||
entries: List[Tuple[str, str, Tuple[str, ...]]] = []
|
||||
for row in BUILTIN_MANAGED_REMOTE_EXTENSION_CATALOG:
|
||||
if row.kind != "agent" or row.install_catalog_id == "tmux":
|
||||
continue
|
||||
agent_cmd = _agent_cmd_for_catalog_id(row.install_catalog_id)
|
||||
if agent_cmd is None:
|
||||
continue
|
||||
entries.append((row.install_catalog_id, row.install_label, agent_cmd))
|
||||
return entries
|
||||
|
||||
|
||||
def _agent_cmd_for_catalog_id(catalog_id: str) -> Optional[Tuple[str, ...]]:
|
||||
"""Map a catalog entry id to the argv we exec inside the tmux session.
|
||||
|
||||
Only known agents are returned — users can add their own by editing
|
||||
``sessions_remote_extensions`` with a matching ``agent_cmd`` setting
|
||||
once that plumbing lands (tracked under v0.6.1 follow-up work).
|
||||
"""
|
||||
if catalog_id == "claude-code":
|
||||
return ("claude",)
|
||||
if catalog_id == "codex-cli":
|
||||
return ("codex",)
|
||||
return None
|
||||
|
||||
|
||||
def _agent_pair_summaries(
|
||||
window: object, active_cache_key: Optional[str]
|
||||
) -> List[AgentPairSummary]:
|
||||
"""Serialise the current pair registry into :class:`AgentPairSummary` rows.
|
||||
|
||||
``active_cache_key`` marks which workspace is driving the window so the
|
||||
"active" glyph lands on the right row. The "attached" glyph mirrors the
|
||||
active flag for now — a later pass could track which pair the current
|
||||
Terminus view points at separately.
|
||||
"""
|
||||
rows: List[AgentPairSummary] = []
|
||||
for pair in list_agent_pairs():
|
||||
is_active = (
|
||||
active_cache_key is not None
|
||||
and pair.workspace_cache_key == active_cache_key
|
||||
and active_agent_pair_id(pair.workspace_cache_key) == pair.pair_id
|
||||
)
|
||||
rows.append(
|
||||
AgentPairSummary(
|
||||
pair_id=pair.pair_id,
|
||||
workspace_label=pair.workspace_label,
|
||||
agent_label=pair.agent_label,
|
||||
is_attached=is_active,
|
||||
is_active=is_active,
|
||||
)
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def _ensure_agent_switcher_view(window: object) -> Optional[object]:
|
||||
"""Create (or re-use) the switcher view in group 2 of the window.
|
||||
|
||||
Returns ``None`` when the Sublime window doesn't expose the APIs we
|
||||
need (unit-test path).
|
||||
"""
|
||||
window_id = _window_identity(window)
|
||||
find_view = getattr(window, "view_from_id", None)
|
||||
existing_id = _AGENT_SWITCHER_VIEW_BY_WINDOW.get(window_id)
|
||||
if existing_id is not None and callable(find_view):
|
||||
try:
|
||||
view = find_view(existing_id)
|
||||
except Exception: # pragma: no cover - defensive
|
||||
view = None
|
||||
if view is not None:
|
||||
return view
|
||||
|
||||
new_file = getattr(window, "new_file", None)
|
||||
if not callable(new_file):
|
||||
return None
|
||||
view = new_file()
|
||||
set_name = getattr(view, "set_name", None)
|
||||
if callable(set_name):
|
||||
set_name("Sessions · Agents")
|
||||
settings_fn = getattr(view, "settings", None)
|
||||
if callable(settings_fn):
|
||||
settings_fn().set(SWITCHER_VIEW_SETTING_KEY, True)
|
||||
settings_fn().set("scratch", True)
|
||||
set_scratch = getattr(view, "set_scratch", None)
|
||||
if callable(set_scratch):
|
||||
set_scratch(True)
|
||||
set_read_only = getattr(view, "set_read_only", None)
|
||||
if callable(set_read_only):
|
||||
set_read_only(True)
|
||||
|
||||
run_command = getattr(window, "run_command", None)
|
||||
if callable(run_command):
|
||||
try:
|
||||
run_command("move_to_group", {"group": 2})
|
||||
except Exception: # pragma: no cover - layout race
|
||||
pass
|
||||
|
||||
view_id = getattr(view, "id", lambda: None)()
|
||||
if view_id is not None:
|
||||
_AGENT_SWITCHER_VIEW_BY_WINDOW[window_id] = view_id
|
||||
return view
|
||||
|
||||
|
||||
def _refresh_agent_switcher_view(window: object) -> None:
|
||||
"""Re-render the switcher view with the current pair list."""
|
||||
view = _ensure_agent_switcher_view(window)
|
||||
if view is None:
|
||||
return
|
||||
settings = _workspace_context_settings_for(window)
|
||||
context = _workspace_context(window, settings, missing_detail_message=False)
|
||||
active_key = context.cache_key if context is not None else None
|
||||
pairs = _agent_pair_summaries(window, active_key)
|
||||
body = render_switcher_body(pairs)
|
||||
settings_fn = getattr(view, "settings", None)
|
||||
if callable(settings_fn):
|
||||
settings_fn().set(
|
||||
"sessions_agent_pairs",
|
||||
[
|
||||
{
|
||||
"pair_id": p.pair_id,
|
||||
"workspace_label": p.workspace_label,
|
||||
"agent_label": p.agent_label,
|
||||
"is_attached": p.is_attached,
|
||||
"is_active": p.is_active,
|
||||
}
|
||||
for p in pairs
|
||||
],
|
||||
)
|
||||
run_command = getattr(view, "run_command", None)
|
||||
if callable(run_command):
|
||||
try:
|
||||
run_command("sessions_render_agent_switcher", {"body": body})
|
||||
except Exception: # pragma: no cover - defensive (view gone mid-render)
|
||||
pass
|
||||
|
||||
|
||||
def _workspace_context_settings_for(window: object) -> "SessionsSettings":
|
||||
"""Return a ``SessionsSettings`` instance for this window (or global)."""
|
||||
return SessionsSettings()
|
||||
|
||||
|
||||
def _apply_three_group_layout(window: object) -> None:
|
||||
"""Set the window into [editor | terminus | switcher] columns."""
|
||||
set_layout = getattr(window, "set_layout", None)
|
||||
if not callable(set_layout):
|
||||
return
|
||||
try:
|
||||
set_layout(build_three_group_layout())
|
||||
except Exception: # pragma: no cover - window race
|
||||
pass
|
||||
|
||||
|
||||
def _open_agent_terminus_view(
|
||||
window: object, session: TmuxAgentSession, title: str
|
||||
) -> None:
|
||||
"""Focus group 1, then fire Terminus with the attach command."""
|
||||
focus_group = getattr(window, "focus_group", None)
|
||||
if callable(focus_group):
|
||||
try:
|
||||
focus_group(1)
|
||||
except Exception: # pragma: no cover
|
||||
pass
|
||||
run_command = getattr(window, "run_command", None)
|
||||
if not callable(run_command):
|
||||
return
|
||||
shell_cmd = " ".join(shlex.quote(a) for a in session.attach_argv)
|
||||
run_command(
|
||||
"terminus_open",
|
||||
{"shell_cmd": shell_cmd, "title": title, "cwd": None},
|
||||
)
|
||||
|
||||
|
||||
def _spawn_agent_session(
|
||||
*,
|
||||
window: object,
|
||||
context: _WorkspaceContext,
|
||||
agent_id: str,
|
||||
agent_label: str,
|
||||
agent_cmd: Tuple[str, ...],
|
||||
) -> None:
|
||||
"""Background task: plan + attach_or_spawn, then open Terminus in group 1."""
|
||||
host_alias = context.recent_entry.host_alias
|
||||
workspace_cache_key = context.cache_key
|
||||
workspace_label = context.recent_entry.remote_root.rsplit("/", 1)[-1] or host_alias
|
||||
broker = _agent_tmux_broker()
|
||||
|
||||
def work() -> None:
|
||||
try:
|
||||
session = broker.plan(host_alias, workspace_cache_key, agent_id, agent_cmd)
|
||||
broker.attach_or_spawn(session)
|
||||
except AgentTmuxError as exc:
|
||||
detail = "Agent session start failed on {}: {}".format(host_alias, exc)
|
||||
_set_timeout(
|
||||
lambda d=detail: _emit_status(ConnectStatus(kind="warning", detail=d))
|
||||
)
|
||||
return
|
||||
|
||||
now = time.time()
|
||||
pair = AgentPair(
|
||||
workspace_cache_key=workspace_cache_key,
|
||||
host_alias=host_alias,
|
||||
agent_id=agent_id,
|
||||
agent_label=agent_label,
|
||||
workspace_label=workspace_label,
|
||||
session_name=session.session_name,
|
||||
created_at=now,
|
||||
last_activated_at=now,
|
||||
)
|
||||
register_agent_pair(pair)
|
||||
|
||||
title = "Agent · {} · {}".format(agent_label, host_alias)
|
||||
|
||||
def finish() -> None:
|
||||
_apply_three_group_layout(window)
|
||||
_open_agent_terminus_view(window, session, title)
|
||||
_refresh_agent_switcher_view(window)
|
||||
_emit_status(
|
||||
ConnectStatus(
|
||||
kind="ready",
|
||||
detail="Agent session ready: {} on {}".format(
|
||||
agent_label, host_alias
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
_set_timeout(finish, 0)
|
||||
|
||||
_run_in_background(
|
||||
work,
|
||||
prioritize=True,
|
||||
task_key="agent_open:{}:{}".format(host_alias, agent_id),
|
||||
task_label="agent_open",
|
||||
)
|
||||
|
||||
|
||||
class SessionsNewAgentSessionCommand(sublime_plugin.WindowCommand):
|
||||
"""Open a quick panel of installed agents, spawn tmux, attach Terminus."""
|
||||
|
||||
def run(self) -> None:
|
||||
"""Prompt for an agent and kick off the spawn+attach flow."""
|
||||
settings = SessionsSettings()
|
||||
context = _workspace_context(self.window, settings, missing_detail_message=True)
|
||||
if context is None:
|
||||
return
|
||||
entries = _installable_agent_entries()
|
||||
if not entries:
|
||||
_status_message(
|
||||
"Sessions: no agent installed yet — "
|
||||
"run 'Sessions: Install Remote Extension' first."
|
||||
)
|
||||
return
|
||||
items = [
|
||||
[label, "agent id: {}".format(agent_id)] for agent_id, label, _ in entries
|
||||
]
|
||||
|
||||
def on_select(choice: int) -> None:
|
||||
if choice < 0 or choice >= len(entries):
|
||||
return
|
||||
agent_id, agent_label, agent_cmd = entries[choice]
|
||||
_spawn_agent_session(
|
||||
window=self.window,
|
||||
context=context,
|
||||
agent_id=agent_id,
|
||||
agent_label=agent_label,
|
||||
agent_cmd=agent_cmd,
|
||||
)
|
||||
|
||||
quick = getattr(self.window, "show_quick_panel", None)
|
||||
if callable(quick):
|
||||
quick(items, on_select)
|
||||
|
||||
|
||||
class SessionsSwitchAgentSessionCommand(sublime_plugin.WindowCommand):
|
||||
"""Re-attach an existing pair's tmux session to the active window."""
|
||||
|
||||
def run(self, pair_id: str = "") -> None:
|
||||
"""Re-open the Terminus view pointed at ``pair_id``'s tmux session."""
|
||||
if not pair_id:
|
||||
_status_message("Sessions: no agent pair id supplied.")
|
||||
return
|
||||
pair = lookup_agent_pair(pair_id)
|
||||
if pair is None:
|
||||
_status_message("Sessions: agent pair {} not found.".format(pair_id))
|
||||
return
|
||||
broker = _agent_tmux_broker()
|
||||
settings = SessionsSettings()
|
||||
context = _workspace_context(
|
||||
self.window, settings, missing_detail_message=False
|
||||
)
|
||||
window = self.window
|
||||
|
||||
def work() -> None:
|
||||
try:
|
||||
session = broker.plan(
|
||||
pair.host_alias,
|
||||
pair.workspace_cache_key,
|
||||
pair.agent_id,
|
||||
_agent_cmd_for_catalog_id(pair.agent_id + "-cli")
|
||||
or _agent_cmd_for_catalog_id(pair.agent_id)
|
||||
or (pair.agent_id,),
|
||||
)
|
||||
if not broker.is_running(pair.host_alias, session.session_name):
|
||||
broker.attach_or_spawn(session)
|
||||
except AgentTmuxError as exc:
|
||||
detail = "Agent switch failed on {}: {}".format(pair.host_alias, exc)
|
||||
_set_timeout(
|
||||
lambda d=detail: _emit_status(
|
||||
ConnectStatus(kind="warning", detail=d)
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
updated = AgentPair(
|
||||
workspace_cache_key=pair.workspace_cache_key,
|
||||
host_alias=pair.host_alias,
|
||||
agent_id=pair.agent_id,
|
||||
agent_label=pair.agent_label,
|
||||
workspace_label=pair.workspace_label,
|
||||
session_name=session.session_name,
|
||||
created_at=pair.created_at,
|
||||
last_activated_at=time.time(),
|
||||
)
|
||||
register_agent_pair(updated)
|
||||
title = "Agent · {} · {}".format(pair.agent_label, pair.host_alias)
|
||||
|
||||
def finish() -> None:
|
||||
_apply_three_group_layout(window)
|
||||
_open_agent_terminus_view(window, session, title)
|
||||
_refresh_agent_switcher_view(window)
|
||||
|
||||
_set_timeout(finish, 0)
|
||||
|
||||
_run_in_background(
|
||||
work,
|
||||
prioritize=True,
|
||||
task_key="agent_switch:{}".format(pair_id),
|
||||
task_label="agent_switch",
|
||||
)
|
||||
_ = context # active_cache_key not required beyond refresh path
|
||||
|
||||
|
||||
class SessionsKillAgentSessionCommand(sublime_plugin.WindowCommand):
|
||||
"""Kill the tmux session for the active workspace's current agent pair."""
|
||||
|
||||
def run(self) -> None:
|
||||
"""Kill the remote tmux session and drop the pair from the registry."""
|
||||
settings = SessionsSettings()
|
||||
context = _workspace_context(self.window, settings, missing_detail_message=True)
|
||||
if context is None:
|
||||
return
|
||||
active_id = active_agent_pair_id(context.cache_key)
|
||||
if active_id is None:
|
||||
_status_message("Sessions: no active agent session in this workspace.")
|
||||
return
|
||||
pair = lookup_agent_pair(active_id)
|
||||
if pair is None:
|
||||
forget_agent_pair(active_id)
|
||||
return
|
||||
broker = _agent_tmux_broker()
|
||||
window = self.window
|
||||
|
||||
def work() -> None:
|
||||
try:
|
||||
broker.kill(pair.host_alias, pair.session_name)
|
||||
except AgentTmuxError as exc:
|
||||
_LOG_AGENT.warning("kill agent session %s failed: %s", active_id, exc)
|
||||
forget_agent_pair(active_id)
|
||||
|
||||
def finish() -> None:
|
||||
_refresh_agent_switcher_view(window)
|
||||
_emit_status(
|
||||
ConnectStatus(
|
||||
kind="ready",
|
||||
detail="Killed agent session {}".format(pair.agent_label),
|
||||
)
|
||||
)
|
||||
|
||||
_set_timeout(finish, 0)
|
||||
|
||||
_run_in_background(
|
||||
work,
|
||||
prioritize=True,
|
||||
task_key="agent_kill:{}".format(active_id),
|
||||
task_label="agent_kill",
|
||||
)
|
||||
|
||||
|
||||
class SessionsShowAgentSwitcherCommand(sublime_plugin.WindowCommand):
|
||||
"""Apply the three-group layout and render the switcher view."""
|
||||
|
||||
def run(self) -> None:
|
||||
"""Open (or refresh) the agent switcher view in group 2."""
|
||||
_apply_three_group_layout(self.window)
|
||||
_refresh_agent_switcher_view(self.window)
|
||||
|
||||
|
||||
_LOG_AGENT = logging.getLogger("sessions.agent")
|
||||
_marimo_session_manager().stop_all()
|
||||
|
||||
|
||||
def _open_connected_host_window(
|
||||
@@ -7514,17 +6888,62 @@ def _write_connected_host_state(mapping: Dict[str, str]) -> None:
|
||||
)
|
||||
|
||||
|
||||
class SessionsOpenRemoteTerminalCommand(sublime_plugin.WindowCommand):
|
||||
"""Open the workspace's remote root in an external OS terminal via ``ssh -t``.
|
||||
|
||||
The command spawns the host's preferred terminal (Sublime's own
|
||||
``new_terminal``, which the user-installed ``Terminal`` package
|
||||
provides) running ``ssh -t <alias> "cd <remote_root> && exec
|
||||
\\$SHELL -l"``. ``ssh -t`` allocates a tty so the remote shell
|
||||
behaves interactively, and ``exec`` ensures Ctrl-D / shell exit
|
||||
closes the terminal cleanly.
|
||||
|
||||
No tmux, no embedded view: the OS terminal owns the lifecycle so
|
||||
Windows IME, scrollback, copy/paste, and font rendering are all
|
||||
handled natively.
|
||||
"""
|
||||
|
||||
def run(self) -> None:
|
||||
"""Open an external OS terminal SSH'd into the workspace's remote root."""
|
||||
settings = SessionsSettings()
|
||||
context = _workspace_context(self.window, settings)
|
||||
if context is None:
|
||||
return
|
||||
host_alias = context.recent_entry.host_alias
|
||||
remote_root = context.recent_entry.remote_root
|
||||
run_command = getattr(self.window, "run_command", None)
|
||||
if not callable(run_command):
|
||||
_status_message("No terminal command is available in this Sublime build.")
|
||||
return
|
||||
remote_invocation = "cd {} && exec ${{SHELL:-/bin/sh}} -l".format(
|
||||
shlex.quote(remote_root),
|
||||
)
|
||||
ssh_cmd = "ssh -t {} {}".format(
|
||||
shlex.quote(host_alias),
|
||||
shlex.quote(remote_invocation),
|
||||
)
|
||||
run_command(
|
||||
"new_terminal",
|
||||
{
|
||||
"cmd": ssh_cmd,
|
||||
"cwd": str(context.local_cache_root),
|
||||
},
|
||||
)
|
||||
_status_message(
|
||||
"Sessions: opening external terminal for {}:{}".format(
|
||||
host_alias, remote_root
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Submodule re-exports.
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# The terminal/tmux command surface lives in ``commands_terminal_tmux`` so this
|
||||
# module stays focused on connect orchestration. Imports happen at the bottom
|
||||
# so the submodule sees a fully-initialized ``commands`` module via
|
||||
# ``from . import commands as _root`` — patches that tests apply with
|
||||
# ``monkeypatch.setattr(commands, "X", ...)`` keep landing on the call site
|
||||
# because the submodule looks the symbol up through ``_root`` rather than
|
||||
# binding it locally at import time.
|
||||
# Imports happen at the bottom so each submodule sees a fully-initialized
|
||||
# ``commands`` module via ``from . import commands as _root`` — tests that
|
||||
# patch ``commands.X`` still land on the call site because submodules look
|
||||
# the symbol up through ``_root`` rather than binding locally at import time.
|
||||
|
||||
from .commands_file_actions import ( # noqa: E402, F401
|
||||
_OPEN_REQUEST_LOCK,
|
||||
@@ -7552,13 +6971,12 @@ from .commands_python_pipeline import ( # noqa: E402, F401
|
||||
_SELECT_PYTHON_CLEAR_SENTINEL,
|
||||
_SELECT_PYTHON_MANUAL_SENTINEL,
|
||||
SessionsClearPythonInterpreterCommand,
|
||||
SessionsOpenRemoteJupyterCommand,
|
||||
SessionsOpenRemoteMarimoCommand,
|
||||
SessionsPythonInterpreterStatusListener,
|
||||
SessionsRegisterJupyterKernelCommand,
|
||||
SessionsRemotePythonPipelineListener,
|
||||
SessionsSelectPythonInterpreterCommand,
|
||||
SessionsSetupRemoteDebuggingCommand,
|
||||
SessionsStopRemoteJupyterCommand,
|
||||
SessionsStopRemoteMarimoCommand,
|
||||
_active_view_remote_notebook_path,
|
||||
_apply_active_python_change,
|
||||
_browse_remote_for_python_interpreter,
|
||||
@@ -7568,15 +6986,14 @@ from .commands_python_pipeline import ( # noqa: E402, F401
|
||||
_effective_sessions_settings_for_remote_python,
|
||||
_erase_active_python_status,
|
||||
_home_dir_for_host,
|
||||
_jupyter_session_manager,
|
||||
_list_remote_directory_task,
|
||||
_marimo_session_manager,
|
||||
_maybe_schedule_remote_python_pipeline_after_cache_push,
|
||||
_merge_sessions_dap_config,
|
||||
_open_remote_jupyter_in_browser,
|
||||
_open_remote_marimo_in_browser,
|
||||
_present_merged_remote_python_pipeline,
|
||||
_probe_active_python_version_task,
|
||||
_prompt_manual_python_interpreter,
|
||||
_register_jupyter_kernel_task,
|
||||
_remote_python_pipeline_targets,
|
||||
_render_remote_debug_instructions,
|
||||
_run_format_then_pipeline_after_cache_push_async,
|
||||
@@ -7591,26 +7008,3 @@ from .commands_python_pipeline import ( # noqa: E402, F401
|
||||
_show_remote_browser_quick_panel,
|
||||
_write_output_panel,
|
||||
)
|
||||
from .commands_terminal_tmux import ( # noqa: E402, F401
|
||||
_DEFAULT_REMOTE_TERMINAL_SHELL,
|
||||
_TERMINUS_TMUX_AVAILABLE_BY_HOST,
|
||||
_TERMINUS_VIEW_BY_HOST,
|
||||
_TERMINUS_VIEW_BY_SESSION_NAME,
|
||||
SessionsAttachRemoteTmuxCommand,
|
||||
SessionsKillRemoteTerminalCommand,
|
||||
SessionsNewRemoteTerminalPaneCommand,
|
||||
SessionsOpenRemoteTerminalCommand,
|
||||
_attach_remote_tmux_session,
|
||||
_build_remote_terminal_invocation,
|
||||
_close_terminus_view_for_session,
|
||||
_focus_existing_terminus_view,
|
||||
_kill_remote_terminal_session,
|
||||
_register_terminus_view_for_host,
|
||||
_register_terminus_view_for_session,
|
||||
_remote_terminal_shell_command,
|
||||
_session_name_belongs_to_host,
|
||||
_sole_live_terminus_view,
|
||||
_spawn_remote_terminal_pane,
|
||||
_terminal_tmux_enabled_for_host,
|
||||
_terminus_view_is_live,
|
||||
)
|
||||
|
||||
@@ -30,16 +30,15 @@ from typing import Dict, List, Optional, Sequence, Tuple
|
||||
from . import commands as _root
|
||||
from .connect_preflight import ConnectStatus
|
||||
from .file_state import RemoteToLocalCacheMapper
|
||||
from .jupyter_hosting import (
|
||||
JupyterHostingError,
|
||||
JupyterSessionManager,
|
||||
_kernel_name_for_workspace,
|
||||
build_notebook_url,
|
||||
)
|
||||
from .lsp_save_preferences import (
|
||||
lsp_fix_all_on_save_enabled,
|
||||
lsp_organize_imports_on_save_enabled,
|
||||
)
|
||||
from .marimo_hosting import (
|
||||
MarimoHostingError,
|
||||
MarimoSessionManager,
|
||||
build_notebook_url,
|
||||
)
|
||||
from .python_interpreter_browser import (
|
||||
DIR_MARKER,
|
||||
PARENT_MARKER,
|
||||
@@ -99,7 +98,7 @@ except ImportError: # pragma: no cover
|
||||
_OPEN_DIAG_VIEW_TS: Dict[int, float] = {}
|
||||
_OPEN_DIAG_DEBOUNCE_S = 1.5
|
||||
|
||||
_JUPYTER_MANAGER: Optional[JupyterSessionManager] = None
|
||||
_MARIMO_MANAGER: Optional[MarimoSessionManager] = None
|
||||
|
||||
_SELECT_PYTHON_MANUAL_SENTINEL = "__manual__"
|
||||
_SELECT_PYTHON_CLEAR_SENTINEL = "__clear__"
|
||||
@@ -592,29 +591,35 @@ class SessionsRemotePythonPipelineListener(sublime_plugin.EventListener):
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Jupyter Lab hosting commands.
|
||||
# Marimo notebook hosting commands.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _jupyter_session_manager() -> JupyterSessionManager:
|
||||
"""Return the process-global Jupyter Lab session manager (lazy-init)."""
|
||||
global _JUPYTER_MANAGER
|
||||
if _JUPYTER_MANAGER is None:
|
||||
_JUPYTER_MANAGER = JupyterSessionManager()
|
||||
return _JUPYTER_MANAGER
|
||||
def _marimo_session_manager() -> MarimoSessionManager:
|
||||
"""Return the process-global marimo session manager (lazy-init)."""
|
||||
global _MARIMO_MANAGER
|
||||
if _MARIMO_MANAGER is None:
|
||||
_MARIMO_MANAGER = MarimoSessionManager()
|
||||
return _MARIMO_MANAGER
|
||||
|
||||
|
||||
def _active_view_remote_notebook_path(
|
||||
window: object,
|
||||
context,
|
||||
) -> Optional[str]:
|
||||
"""Return the remote POSIX path of the active ``.ipynb`` view, if any."""
|
||||
"""Return the remote POSIX path of the active ``.py`` view, if any.
|
||||
|
||||
Marimo notebooks are reactive ``.py`` files; we hand the active view's
|
||||
remote path to the URL builder when present and let marimo decide
|
||||
whether the file is in fact a marimo notebook (the edit server simply
|
||||
opens whichever ``.py`` it gets).
|
||||
"""
|
||||
active_view_fn = getattr(window, "active_view", None)
|
||||
if not callable(active_view_fn):
|
||||
return None
|
||||
view = active_view_fn()
|
||||
file_name = _root._view_file_name(view)
|
||||
if not isinstance(file_name, str) or not file_name.endswith(".ipynb"):
|
||||
if not isinstance(file_name, str) or not file_name.endswith(".py"):
|
||||
return None
|
||||
mapper = RemoteToLocalCacheMapper(
|
||||
workspace_cache_key=context.cache_key,
|
||||
@@ -624,7 +629,7 @@ def _active_view_remote_notebook_path(
|
||||
return mapper.remote_path_for_local_cache_file(Path(file_name))
|
||||
|
||||
|
||||
def _open_remote_jupyter_in_browser(
|
||||
def _open_remote_marimo_in_browser(
|
||||
window: object,
|
||||
*,
|
||||
notebook_path: Optional[str] = None,
|
||||
@@ -635,23 +640,16 @@ def _open_remote_jupyter_in_browser(
|
||||
return
|
||||
host_alias = context.recent_entry.host_alias
|
||||
workspace_root = context.recent_entry.remote_root
|
||||
workspace_cache_key = context.cache_key
|
||||
active_python = read_active_interpreter(window)
|
||||
resolved_notebook = notebook_path or _active_view_remote_notebook_path(
|
||||
window, context
|
||||
)
|
||||
|
||||
def work() -> None:
|
||||
manager = _root._jupyter_session_manager()
|
||||
manager = _root._marimo_session_manager()
|
||||
try:
|
||||
info = manager.ensure_started(
|
||||
host_alias,
|
||||
workspace_root,
|
||||
kernel_python=active_python,
|
||||
workspace_cache_key=workspace_cache_key,
|
||||
)
|
||||
except JupyterHostingError as exc:
|
||||
detail = "Jupyter Lab start failed on {}: {}".format(host_alias, exc)
|
||||
info = manager.ensure_started(host_alias, workspace_root)
|
||||
except MarimoHostingError as exc:
|
||||
detail = "marimo start failed on {}: {}".format(host_alias, exc)
|
||||
_root._set_timeout(
|
||||
lambda d=detail: _root._emit_status(
|
||||
ConnectStatus(kind="warning", detail=d)
|
||||
@@ -660,55 +658,44 @@ def _open_remote_jupyter_in_browser(
|
||||
return
|
||||
url = build_notebook_url(info, resolved_notebook)
|
||||
_LOG.info(
|
||||
"jupyter_open: built url=%r resolved_notebook=%r kernel=%r",
|
||||
"marimo_open: built url=%r resolved_notebook=%r",
|
||||
url,
|
||||
resolved_notebook,
|
||||
info.kernel_name,
|
||||
)
|
||||
|
||||
if info.kernel_name:
|
||||
ready_detail = (
|
||||
"Jupyter Lab ready on {} (kernel: {}) — opening browser".format(
|
||||
host_alias, info.kernel_name
|
||||
)
|
||||
)
|
||||
else:
|
||||
ready_detail = "Jupyter Lab ready on {} — opening browser".format(
|
||||
host_alias
|
||||
)
|
||||
ready_detail = "marimo ready on {} — opening browser".format(host_alias)
|
||||
|
||||
def finish() -> None:
|
||||
_LOG.info("jupyter_open: finish() reached, url=%r", url)
|
||||
_LOG.info("marimo_open: finish() reached, url=%r", url)
|
||||
_root._emit_status(ConnectStatus(kind="ready", detail=ready_detail))
|
||||
try:
|
||||
_LOG.info("jupyter_open: about to call webbrowser.open(%r)", url)
|
||||
_LOG.info("marimo_open: about to call webbrowser.open(%r)", url)
|
||||
webbrowser.open(url)
|
||||
except Exception: # pragma: no cover - best-effort browser open
|
||||
_LOG.exception("jupyter_open: webbrowser.open() raised")
|
||||
_LOG.exception("marimo_open: webbrowser.open() raised")
|
||||
|
||||
_root._set_timeout(finish, 0)
|
||||
|
||||
_root._run_in_background(
|
||||
work,
|
||||
prioritize=True,
|
||||
task_key="jupyter_open:{}".format(host_alias),
|
||||
task_label="jupyter_open",
|
||||
task_key="marimo_open:{}".format(host_alias),
|
||||
task_label="marimo_open",
|
||||
)
|
||||
|
||||
|
||||
class SessionsOpenRemoteJupyterCommand(sublime_plugin.WindowCommand):
|
||||
"""Ensure remote Jupyter Lab is running and open the tunneled URL in a browser."""
|
||||
class SessionsOpenRemoteMarimoCommand(sublime_plugin.WindowCommand):
|
||||
"""Ensure remote marimo is running and open the tunneled URL in a browser."""
|
||||
|
||||
def run(self, notebook_path: Optional[str] = None) -> None:
|
||||
"""Launch / reuse a remote Jupyter server and open it in the local browser."""
|
||||
_open_remote_jupyter_in_browser(self.window, notebook_path=notebook_path)
|
||||
"""Launch / reuse a remote marimo server and open it in the local browser."""
|
||||
_open_remote_marimo_in_browser(self.window, notebook_path=notebook_path)
|
||||
|
||||
|
||||
class SessionsStopRemoteJupyterCommand(sublime_plugin.WindowCommand):
|
||||
"""Stop the remote Jupyter Lab server associated with the active workspace."""
|
||||
class SessionsStopRemoteMarimoCommand(sublime_plugin.WindowCommand):
|
||||
"""Stop the remote marimo server associated with the active workspace."""
|
||||
|
||||
def run(self) -> None:
|
||||
"""Tear down the Jupyter server + SSH tunnel for this window's host."""
|
||||
"""Tear down the marimo server + SSH tunnel for this window's host."""
|
||||
settings = SessionsSettings()
|
||||
context = _root._workspace_context(
|
||||
self.window, settings, missing_detail_message=True
|
||||
@@ -716,67 +703,15 @@ class SessionsStopRemoteJupyterCommand(sublime_plugin.WindowCommand):
|
||||
if context is None:
|
||||
return
|
||||
host_alias = context.recent_entry.host_alias
|
||||
_root._jupyter_session_manager().stop(host_alias)
|
||||
_root._marimo_session_manager().stop(host_alias)
|
||||
_root._emit_status(
|
||||
ConnectStatus(
|
||||
kind="ready",
|
||||
detail="Stopped remote Jupyter Lab on {}".format(host_alias),
|
||||
detail="Stopped remote marimo on {}".format(host_alias),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class SessionsRegisterJupyterKernelCommand(sublime_plugin.WindowCommand):
|
||||
"""Install ipykernel and register a Sessions kernelspec for the active Python."""
|
||||
|
||||
def run(self) -> None:
|
||||
"""Register a remote Jupyter kernelspec pointing at the active interpreter."""
|
||||
active_python = read_active_interpreter(self.window)
|
||||
if not active_python:
|
||||
_root._status_message("Sessions: select Python interpreter first.")
|
||||
return
|
||||
settings = SessionsSettings()
|
||||
context = _root._workspace_context(
|
||||
self.window, settings, missing_detail_message=True
|
||||
)
|
||||
if context is None:
|
||||
return
|
||||
host_alias = context.recent_entry.host_alias
|
||||
workspace_root = context.recent_entry.remote_root
|
||||
kernel_name = _kernel_name_for_workspace(workspace_root, context.cache_key)
|
||||
_root._run_in_background(
|
||||
_register_jupyter_kernel_task,
|
||||
host_alias,
|
||||
active_python,
|
||||
kernel_name,
|
||||
task_key="sessions_register_jupyter_kernel:{}".format(host_alias),
|
||||
task_label="sessions.register_jupyter_kernel",
|
||||
)
|
||||
|
||||
|
||||
def _register_jupyter_kernel_task(
|
||||
host_alias: str,
|
||||
kernel_python: str,
|
||||
kernel_name: str,
|
||||
) -> None:
|
||||
"""Background worker for :class:`SessionsRegisterJupyterKernelCommand`."""
|
||||
try:
|
||||
_root._jupyter_session_manager().register_kernelspec_only(
|
||||
host_alias,
|
||||
kernel_python,
|
||||
kernel_name,
|
||||
)
|
||||
except JupyterHostingError as exc:
|
||||
detail = "Kernel registration failed on {}: {}".format(host_alias, exc)
|
||||
_root._set_timeout(
|
||||
lambda d=detail: _root._emit_status(ConnectStatus(kind="warning", detail=d))
|
||||
)
|
||||
return
|
||||
detail = "Registered Jupyter kernel {} for {}".format(kernel_name, kernel_python)
|
||||
_root._set_timeout(
|
||||
lambda d=detail: _root._emit_status(ConnectStatus(kind="ready", detail=d))
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Active Python interpreter selection / status / browser flows.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,794 +0,0 @@
|
||||
"""Terminal/tmux command handlers extracted from :mod:`commands`.
|
||||
|
||||
This submodule owns ``Sessions: Open Remote Terminal``,
|
||||
``New Remote Terminal Pane``, ``Kill Remote Terminal``, and
|
||||
``Attach Remote Tmux Session``, along with their private helpers and
|
||||
the per-host/per-session Terminus view caches.
|
||||
|
||||
The parent :mod:`commands` module re-exports every public name in this
|
||||
file so external imports keep resolving through ``commands.X`` —
|
||||
``sublime/plugin.py`` enumerates the entrypoints by name and the test
|
||||
suite reaches into private helpers / module state. To preserve that
|
||||
contract under refactor, we import the parent module as ``_root`` and
|
||||
look up patchable symbols (``list_terminal_sessions``, ``_status_message``,
|
||||
the ``sublime`` shim, ...) through ``_root.X`` so
|
||||
``monkeypatch.setattr(commands, "...", ...)`` inside tests still
|
||||
intercepts the call site.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shlex
|
||||
from typing import Optional
|
||||
|
||||
from . import commands as _root
|
||||
from .terminal_tmux_session import (
|
||||
SESSION_NAME_PREFIX,
|
||||
TerminalTmuxSessionError,
|
||||
build_remote_tmux_invocation,
|
||||
next_terminal_session_name,
|
||||
session_name_for_host,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-host / per-session Terminus view caches.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# ``Sessions: Open Remote Terminal`` persistent-session state.
|
||||
# Maps ``host_alias`` → the Terminus view that currently hosts the
|
||||
# ``sessions-term-<alias>`` tmux attach for that host. On a second
|
||||
# invocation for the same host we focus the live view instead of
|
||||
# spawning a new Terminus pane; if the view has since closed, we spawn
|
||||
# a new attach (tmux holds the remote shell + history alive on detach).
|
||||
# The prefix ``sessions-term-`` is owned by Track C2 and is **disjoint**
|
||||
# from the ``sessions-agent-`` prefix owned by ``agent_tmux.py``
|
||||
# (Track D) — the two never collide on the remote host.
|
||||
_TERMINUS_VIEW_BY_HOST: dict[str, object] = {}
|
||||
# ``host_alias`` → bool: whether the remote probe has confirmed that
|
||||
# ``tmux`` is on ``$PATH``. Missing key means we haven't probed yet.
|
||||
_TERMINUS_TMUX_AVAILABLE_BY_HOST: dict[str, bool] = {}
|
||||
# Maps ``tmux session name`` → the Terminus view hosting that pane.
|
||||
# Tracks numbered "new pane" sessions opened by ``Sessions: New Remote
|
||||
# Terminal Pane`` so the kill command can close the matching tab even
|
||||
# when the user hopped windows after spawning. The base session also
|
||||
# registers here for the same reason.
|
||||
_TERMINUS_VIEW_BY_SESSION_NAME: dict[str, object] = {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Remote shell command resolution.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# ``-i`` is the piece readline/bash-completion actually gates on. Without it
|
||||
# bash considers the session non-interactive, skips ``/etc/bashrc`` and user
|
||||
# ``.bashrc`` on some distros (Amazon Linux, Debian), and Tab inserts a literal
|
||||
# ``\t`` instead of invoking the completion machinery. ``-l`` stays so the
|
||||
# shell also loads ``~/.profile`` / ``~/.bash_profile`` (PATH additions,
|
||||
# pyenv shims, etc.). Users can still override via
|
||||
# ``sessions_remote_terminal_shell``.
|
||||
_DEFAULT_REMOTE_TERMINAL_SHELL = "bash -il"
|
||||
|
||||
|
||||
def _remote_terminal_shell_command() -> str:
|
||||
"""Return configured remote shell command for ``Sessions: Open Remote Terminal``."""
|
||||
load_settings = getattr(_root.sublime, "load_settings", None)
|
||||
if not callable(load_settings):
|
||||
return _DEFAULT_REMOTE_TERMINAL_SHELL
|
||||
stored = load_settings("Sessions.sublime-settings")
|
||||
getter = getattr(stored, "get", None)
|
||||
if not callable(getter):
|
||||
return _DEFAULT_REMOTE_TERMINAL_SHELL
|
||||
raw = getter("sessions_remote_terminal_shell", _DEFAULT_REMOTE_TERMINAL_SHELL)
|
||||
value = str(raw).strip() if raw is not None else ""
|
||||
if not value or "\n" in value or "\r" in value:
|
||||
return _DEFAULT_REMOTE_TERMINAL_SHELL
|
||||
return value
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ``Sessions: Open Remote Terminal``
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class SessionsOpenRemoteTerminalCommand(_root.sublime_plugin.WindowCommand):
|
||||
"""Open a terminal already attached to the workspace SSH host/root.
|
||||
|
||||
Prefer reusing an existing Terminus view for the same ``host_alias``
|
||||
(so switching windows and coming back returns to the same shell)
|
||||
and wrap the remote invocation in ``tmux new-session -A -s
|
||||
sessions-term-<alias>`` so the shell survives a closed view. When
|
||||
the remote host has no ``tmux``, fall back to the previous
|
||||
direct-shell spawn — the user sees a status hint on the first
|
||||
attempt for that host.
|
||||
"""
|
||||
|
||||
def run(self) -> None:
|
||||
"""Prefer Terminus; fall back to Sublime's ``new_terminal`` when available."""
|
||||
settings = _root.SessionsSettings()
|
||||
context = _root._workspace_context(self.window, settings)
|
||||
if context is None:
|
||||
return
|
||||
host_alias = context.recent_entry.host_alias
|
||||
remote_root = context.recent_entry.remote_root
|
||||
shell_command = _remote_terminal_shell_command()
|
||||
shell_preamble = "cd {} && (stty sane -ixon 2>/dev/null || true)".format(
|
||||
shlex.quote(remote_root),
|
||||
)
|
||||
run_command = getattr(self.window, "run_command", None)
|
||||
if not callable(run_command):
|
||||
_root._status_message(
|
||||
"No terminal command is available in this Sublime build."
|
||||
)
|
||||
return
|
||||
|
||||
# View-reuse fast path: focus the existing Terminus pane rather
|
||||
# than spawn a second one. tmux keeps the shell attached on the
|
||||
# remote side, so reattaching into the same session picks up
|
||||
# history + running processes.
|
||||
reused_view = _focus_existing_terminus_view(self.window, host_alias)
|
||||
if reused_view is not None:
|
||||
_root._status_message(
|
||||
"Sessions terminal for {} refocused".format(host_alias),
|
||||
)
|
||||
return
|
||||
|
||||
has_terminus = False
|
||||
find_resources = getattr(_root.sublime, "find_resources", None)
|
||||
if callable(find_resources):
|
||||
try:
|
||||
has_terminus = bool(find_resources("Terminus.sublime-settings"))
|
||||
except (TypeError, ValueError, RuntimeError):
|
||||
has_terminus = False
|
||||
|
||||
use_tmux = _terminal_tmux_enabled_for_host(host_alias)
|
||||
remote_invocation = _build_remote_terminal_invocation(
|
||||
host_alias=host_alias,
|
||||
shell_preamble=shell_preamble,
|
||||
shell_command=shell_command,
|
||||
use_tmux=use_tmux,
|
||||
)
|
||||
|
||||
if has_terminus:
|
||||
run_command(
|
||||
"terminus_open",
|
||||
{
|
||||
"cmd": ["ssh", "-tt", host_alias, remote_invocation],
|
||||
"cwd": str(context.local_cache_root),
|
||||
"show_in_panel": True,
|
||||
"panel_name": "Terminus",
|
||||
"auto_close": False,
|
||||
"title": "SSH {}:{}".format(host_alias, remote_root),
|
||||
"focus": True,
|
||||
},
|
||||
)
|
||||
_register_terminus_view_for_host(self.window, host_alias)
|
||||
else:
|
||||
ssh_shell_cmd = "ssh -tt {} {}".format(
|
||||
shlex.quote(host_alias),
|
||||
shlex.quote(remote_invocation),
|
||||
)
|
||||
run_command(
|
||||
"new_terminal",
|
||||
{
|
||||
"cmd": ssh_shell_cmd,
|
||||
},
|
||||
)
|
||||
_root._status_message(
|
||||
"Sessions terminal attached to {} {}".format(host_alias, remote_root)
|
||||
)
|
||||
|
||||
|
||||
def _terminal_tmux_enabled_for_host(host_alias: str) -> bool:
|
||||
"""Return whether tmux should wrap the remote shell for ``host_alias``.
|
||||
|
||||
Runs a one-shot ``command -v tmux`` probe on the first call per
|
||||
host; the result is cached in ``_TERMINUS_TMUX_AVAILABLE_BY_HOST``
|
||||
so subsequent invocations don't pay the SSH round-trip. When tmux
|
||||
is missing we emit a status hint pointing at the install step and
|
||||
fall back to the pre-C2 direct-shell spawn.
|
||||
"""
|
||||
cached = _TERMINUS_TMUX_AVAILABLE_BY_HOST.get(host_alias)
|
||||
if cached is not None:
|
||||
return cached
|
||||
try:
|
||||
session_name_for_host(host_alias)
|
||||
except TerminalTmuxSessionError:
|
||||
# Host alias would not round-trip safely into a tmux session
|
||||
# name; silently fall back to the direct spawn.
|
||||
_TERMINUS_TMUX_AVAILABLE_BY_HOST[host_alias] = False
|
||||
return False
|
||||
probe = _root.probe_tmux_available(host_alias)
|
||||
_TERMINUS_TMUX_AVAILABLE_BY_HOST[host_alias] = probe.available
|
||||
if not probe.available:
|
||||
_root._status_message(
|
||||
"Sessions: tmux not found on {} — install via "
|
||||
"Sessions: Install Remote Extension (tmux). Falling back to a "
|
||||
"non-persistent shell.".format(host_alias),
|
||||
)
|
||||
return probe.available
|
||||
|
||||
|
||||
def _build_remote_terminal_invocation(
|
||||
*,
|
||||
host_alias: str,
|
||||
shell_preamble: str,
|
||||
shell_command: str,
|
||||
use_tmux: bool,
|
||||
) -> str:
|
||||
"""Return the remote invocation string for the SSH positional arg."""
|
||||
if not use_tmux:
|
||||
return "{} && exec {}".format(shell_preamble, shell_command)
|
||||
session_name = session_name_for_host(host_alias)
|
||||
return build_remote_tmux_invocation(
|
||||
session_name=session_name,
|
||||
shell_preamble=shell_preamble,
|
||||
shell_command=shell_command,
|
||||
)
|
||||
|
||||
|
||||
def _focus_existing_terminus_view(window: object, host_alias: str) -> Optional[object]:
|
||||
"""Focus the live Terminus view for ``host_alias`` if one exists.
|
||||
|
||||
Returns the view that was refocused, or ``None`` when no live view
|
||||
is cached. A cached view whose settings no longer carry the
|
||||
``terminus_view`` marker (closed or reclaimed) is evicted and
|
||||
treated as absent.
|
||||
"""
|
||||
view = _TERMINUS_VIEW_BY_HOST.get(host_alias)
|
||||
if view is None:
|
||||
return None
|
||||
settings_fn = getattr(view, "settings", None)
|
||||
is_live = False
|
||||
if callable(settings_fn):
|
||||
try:
|
||||
stored = settings_fn()
|
||||
except Exception: # pragma: no cover - defensive
|
||||
stored = None
|
||||
if stored is not None:
|
||||
getter = getattr(stored, "get", None)
|
||||
if callable(getter):
|
||||
try:
|
||||
is_live = bool(getter("terminus_view"))
|
||||
except Exception: # pragma: no cover - defensive
|
||||
is_live = False
|
||||
if not is_live:
|
||||
_TERMINUS_VIEW_BY_HOST.pop(host_alias, None)
|
||||
return None
|
||||
focus_view = getattr(window, "focus_view", None)
|
||||
if callable(focus_view):
|
||||
try:
|
||||
focus_view(view)
|
||||
except Exception: # pragma: no cover - defensive
|
||||
return None
|
||||
return view
|
||||
|
||||
|
||||
def _register_terminus_view_for_host(window: object, host_alias: str) -> None:
|
||||
"""Record the most-recently-spawned Terminus view for ``host_alias``.
|
||||
|
||||
The Terminus package spawns the new view inside its own queue, so
|
||||
the view isn't necessarily the active one by the time
|
||||
``run_command("terminus_open", …)`` returns. We pick the freshest
|
||||
view that carries the ``terminus_view`` marker — that's the pane we
|
||||
just asked Terminus to open.
|
||||
"""
|
||||
views_fn = getattr(window, "views", None)
|
||||
if not callable(views_fn):
|
||||
return
|
||||
try:
|
||||
views = list(views_fn())
|
||||
except Exception: # pragma: no cover - defensive
|
||||
return
|
||||
for candidate in reversed(views):
|
||||
settings_fn = getattr(candidate, "settings", None)
|
||||
if not callable(settings_fn):
|
||||
continue
|
||||
try:
|
||||
stored = settings_fn()
|
||||
except Exception: # pragma: no cover - defensive
|
||||
continue
|
||||
getter = getattr(stored, "get", None)
|
||||
if not callable(getter):
|
||||
continue
|
||||
try:
|
||||
marker = bool(getter("terminus_view"))
|
||||
except Exception: # pragma: no cover - defensive
|
||||
marker = False
|
||||
if marker:
|
||||
_TERMINUS_VIEW_BY_HOST[host_alias] = candidate
|
||||
return
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Multi-pane terminal commands (Cluster E, 2026-04-25 retest).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class SessionsNewRemoteTerminalPaneCommand(_root.sublime_plugin.WindowCommand):
|
||||
"""Spawn a fresh numbered tmux session in a new Terminus tab.
|
||||
|
||||
Unlike :class:`SessionsOpenRemoteTerminalCommand`, this never
|
||||
reattaches: each invocation picks the next free numbered name
|
||||
(``sessions-term-<alias>-2``, ``-3``, …) so the user can keep
|
||||
multiple independent shells side-by-side. When the remote host
|
||||
is missing ``tmux`` the spawn falls back to a non-persistent
|
||||
shell — the same fallback used by the main "open" command.
|
||||
"""
|
||||
|
||||
def run(self) -> None:
|
||||
"""Pick the next numbered session name and spawn it via Terminus."""
|
||||
settings = _root.SessionsSettings()
|
||||
context = _root._workspace_context(self.window, settings)
|
||||
if context is None:
|
||||
return
|
||||
host_alias = context.recent_entry.host_alias
|
||||
try:
|
||||
base = session_name_for_host(host_alias)
|
||||
except TerminalTmuxSessionError as exc:
|
||||
_root._status_message("Sessions: cannot open terminal — {}".format(exc))
|
||||
return
|
||||
|
||||
# When tmux is unavailable, numbering does not apply — fall
|
||||
# back to spawning the base session and let the helper apply
|
||||
# the existing direct-shell fallback.
|
||||
if not _terminal_tmux_enabled_for_host(host_alias):
|
||||
_spawn_remote_terminal_pane(
|
||||
window=self.window,
|
||||
context=context,
|
||||
session_name_override=base,
|
||||
)
|
||||
return
|
||||
|
||||
existing = _root.list_terminal_sessions(host_alias)
|
||||
try:
|
||||
session_name = next_terminal_session_name(host_alias, existing)
|
||||
except TerminalTmuxSessionError as exc: # pragma: no cover - validated above
|
||||
_root._status_message("Sessions: cannot open terminal — {}".format(exc))
|
||||
return
|
||||
_spawn_remote_terminal_pane(
|
||||
window=self.window,
|
||||
context=context,
|
||||
session_name_override=session_name,
|
||||
)
|
||||
|
||||
|
||||
class SessionsKillRemoteTerminalCommand(_root.sublime_plugin.WindowCommand):
|
||||
"""Kill a remote tmux terminal session and close its Terminus tab.
|
||||
|
||||
Lists every ``sessions-term-<alias>...`` session running on the
|
||||
workspace's host, lets the user pick one in a quick panel, then
|
||||
runs ``tmux kill-session -t <name>`` over SSH. If the matching
|
||||
Terminus view is still open, it's closed too — that's the
|
||||
"clean-up state" affordance the main command can't offer (a
|
||||
plain ``tmux detach`` from inside the pane closes the SSH tunnel
|
||||
rather than the session).
|
||||
"""
|
||||
|
||||
def run(self) -> None:
|
||||
"""Show a quick panel of live terminal sessions and kill the choice."""
|
||||
settings = _root.SessionsSettings()
|
||||
context = _root._workspace_context(self.window, settings)
|
||||
if context is None:
|
||||
return
|
||||
host_alias = context.recent_entry.host_alias
|
||||
try:
|
||||
session_name_for_host(host_alias)
|
||||
except TerminalTmuxSessionError as exc:
|
||||
_root._status_message("Sessions: cannot kill terminal — {}".format(exc))
|
||||
return
|
||||
|
||||
if not _terminal_tmux_enabled_for_host(host_alias):
|
||||
_root._status_message(
|
||||
"Sessions: tmux is not available on {} — "
|
||||
"no remote terminal sessions to kill.".format(host_alias)
|
||||
)
|
||||
return
|
||||
|
||||
sessions = _root.list_terminal_sessions(host_alias)
|
||||
host_sessions = sorted(
|
||||
name for name in sessions if _session_name_belongs_to_host(name, host_alias)
|
||||
)
|
||||
if not host_sessions:
|
||||
_root._status_message(
|
||||
"Sessions: no remote terminal sessions on {} to kill.".format(
|
||||
host_alias
|
||||
)
|
||||
)
|
||||
return
|
||||
items = [
|
||||
[name, "tmux session on {}".format(host_alias)] for name in host_sessions
|
||||
]
|
||||
window = self.window
|
||||
|
||||
def on_select(choice: int) -> None:
|
||||
if choice < 0 or choice >= len(host_sessions):
|
||||
return
|
||||
target = host_sessions[choice]
|
||||
_kill_remote_terminal_session(window, host_alias, target)
|
||||
|
||||
quick = getattr(self.window, "show_quick_panel", None)
|
||||
if callable(quick):
|
||||
quick(items, on_select)
|
||||
|
||||
|
||||
class SessionsAttachRemoteTmuxCommand(_root.sublime_plugin.WindowCommand):
|
||||
"""Attach a Terminus pane to **any** existing remote tmux session.
|
||||
|
||||
Sibling of :class:`SessionsOpenRemoteTerminalCommand`, but reaches
|
||||
across the ``sessions-term-*`` namespace boundary: lists every
|
||||
tmux session running on the workspace's host (foreign sessions
|
||||
the user started outside Sessions, plus Sessions-owned ones), and
|
||||
attaches a Terminus pane to whichever the user picks. Read-only
|
||||
attach — Sessions never tries to kill or write-back to a foreign
|
||||
session, so this command is safe to point at long-lived shells
|
||||
spun up by other tools.
|
||||
|
||||
Plugged in to address the maintainer-flagged gap (2026-04-25):
|
||||
"영구 terminal 구현을 위해서 아예 default로 tmux를 열다보니까 기존
|
||||
다른 tmux에 연결할 수 없는건 단점." The other terminal commands
|
||||
stay scoped to ``sessions-term-*`` so kill / new-pane semantics
|
||||
can never reach into a foreign session by accident.
|
||||
"""
|
||||
|
||||
def run(self) -> None:
|
||||
"""List remote tmux sessions and attach a Terminus pane to the pick."""
|
||||
settings = _root.SessionsSettings()
|
||||
context = _root._workspace_context(self.window, settings)
|
||||
if context is None:
|
||||
return
|
||||
host_alias = context.recent_entry.host_alias
|
||||
if not _terminal_tmux_enabled_for_host(host_alias):
|
||||
_root._status_message(
|
||||
"Sessions: tmux is not available on {} — no remote tmux "
|
||||
"sessions to attach.".format(host_alias)
|
||||
)
|
||||
return
|
||||
|
||||
sessions = _root.list_all_remote_tmux_sessions(host_alias)
|
||||
if not sessions:
|
||||
_root._status_message(
|
||||
"Sessions: no remote tmux sessions running on {}.".format(host_alias)
|
||||
)
|
||||
return
|
||||
|
||||
items = [
|
||||
[
|
||||
name,
|
||||
(
|
||||
"Sessions-owned tmux session on {}".format(host_alias)
|
||||
if name.startswith(SESSION_NAME_PREFIX)
|
||||
else "foreign tmux session on {}".format(host_alias)
|
||||
),
|
||||
]
|
||||
for name in sessions
|
||||
]
|
||||
window = self.window
|
||||
remote_root = context.recent_entry.remote_root
|
||||
|
||||
def on_select(choice: int) -> None:
|
||||
if choice < 0 or choice >= len(sessions):
|
||||
return
|
||||
target = sessions[choice]
|
||||
_attach_remote_tmux_session(
|
||||
window=window,
|
||||
host_alias=host_alias,
|
||||
remote_root=remote_root,
|
||||
session_name=target,
|
||||
)
|
||||
|
||||
quick = getattr(self.window, "show_quick_panel", None)
|
||||
if callable(quick):
|
||||
quick(items, on_select)
|
||||
|
||||
|
||||
def _attach_remote_tmux_session(
|
||||
*,
|
||||
window: object,
|
||||
host_alias: str,
|
||||
remote_root: str,
|
||||
session_name: str,
|
||||
) -> None:
|
||||
"""Open a Terminus pane attached to ``session_name`` via ``tmux attach``.
|
||||
|
||||
Uses ``ssh -tt`` so tmux gets a real PTY (it's an interactive
|
||||
shell on the user's side, unlike the spawn-time agent flow which
|
||||
is non-interactive). When Terminus isn't installed, falls back to
|
||||
Sublime's ``new_terminal`` command — same fallback pattern as
|
||||
:class:`SessionsOpenRemoteTerminalCommand`. The pane is not
|
||||
registered in the Sessions per-host view cache because foreign
|
||||
sessions are not part of the Sessions kill / lifecycle namespace.
|
||||
"""
|
||||
run_command = getattr(window, "run_command", None)
|
||||
if not callable(run_command):
|
||||
_root._status_message("No terminal command is available in this Sublime build.")
|
||||
return
|
||||
|
||||
has_terminus = False
|
||||
find_resources = getattr(_root.sublime, "find_resources", None)
|
||||
if callable(find_resources):
|
||||
try:
|
||||
has_terminus = bool(find_resources("Terminus.sublime-settings"))
|
||||
except (TypeError, ValueError, RuntimeError):
|
||||
has_terminus = False
|
||||
|
||||
remote_invocation = "tmux attach-session -t {}".format(shlex.quote(session_name))
|
||||
if has_terminus:
|
||||
run_command(
|
||||
"terminus_open",
|
||||
{
|
||||
"cmd": ["ssh", "-tt", host_alias, remote_invocation],
|
||||
"cwd": str(remote_root),
|
||||
"show_in_panel": True,
|
||||
"panel_name": "Terminus",
|
||||
"auto_close": False,
|
||||
"title": "tmux: {} @ {}".format(session_name, host_alias),
|
||||
"focus": True,
|
||||
},
|
||||
)
|
||||
else:
|
||||
ssh_shell_cmd = "ssh -tt {} {}".format(
|
||||
shlex.quote(host_alias),
|
||||
shlex.quote(remote_invocation),
|
||||
)
|
||||
run_command(
|
||||
"new_terminal",
|
||||
{
|
||||
"cmd": ["bash", "-lc", ssh_shell_cmd],
|
||||
"title": "tmux: {} @ {}".format(session_name, host_alias),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _spawn_remote_terminal_pane(
|
||||
*,
|
||||
window: object,
|
||||
context: "_root._WorkspaceContext",
|
||||
session_name_override: Optional[str],
|
||||
) -> None:
|
||||
"""Spawn a Terminus pane attached to ``session_name_override`` over SSH.
|
||||
|
||||
Used by :class:`SessionsNewRemoteTerminalPaneCommand` to open a
|
||||
distinct numbered tmux session per invocation. The view is
|
||||
registered in the per-session cache (not the per-host cache) so
|
||||
repeated invocations always create a new tab and a later
|
||||
:class:`SessionsKillRemoteTerminalCommand` can close the matching
|
||||
pane regardless of which window currently owns it.
|
||||
|
||||
``session_name_override=None`` retains the pre-tmux direct-shell
|
||||
behaviour for hosts whose alias cannot be rendered safely as a
|
||||
tmux session name (kept here so the new-pane command degrades
|
||||
gracefully on those hosts).
|
||||
"""
|
||||
host_alias = context.recent_entry.host_alias
|
||||
remote_root = context.recent_entry.remote_root
|
||||
shell_command = _remote_terminal_shell_command()
|
||||
shell_preamble = "cd {} && (stty sane -ixon 2>/dev/null || true)".format(
|
||||
shlex.quote(remote_root),
|
||||
)
|
||||
run_command = getattr(window, "run_command", None)
|
||||
if not callable(run_command):
|
||||
_root._status_message("No terminal command is available in this Sublime build.")
|
||||
return
|
||||
|
||||
has_terminus = False
|
||||
find_resources = getattr(_root.sublime, "find_resources", None)
|
||||
if callable(find_resources):
|
||||
try:
|
||||
has_terminus = bool(find_resources("Terminus.sublime-settings"))
|
||||
except (TypeError, ValueError, RuntimeError):
|
||||
has_terminus = False
|
||||
|
||||
use_tmux = session_name_override is not None and _terminal_tmux_enabled_for_host(
|
||||
host_alias
|
||||
)
|
||||
effective_session = session_name_override if use_tmux else None
|
||||
if effective_session is not None:
|
||||
remote_invocation = build_remote_tmux_invocation(
|
||||
session_name=effective_session,
|
||||
shell_preamble=shell_preamble,
|
||||
shell_command=shell_command,
|
||||
)
|
||||
else:
|
||||
remote_invocation = "{} && exec {}".format(shell_preamble, shell_command)
|
||||
|
||||
title_suffix = ""
|
||||
if effective_session is not None:
|
||||
try:
|
||||
base = session_name_for_host(host_alias)
|
||||
except TerminalTmuxSessionError:
|
||||
base = None
|
||||
if base is not None and effective_session != base:
|
||||
tail = effective_session[len(base) + 1 :]
|
||||
if tail:
|
||||
title_suffix = " (#{})".format(tail)
|
||||
|
||||
if has_terminus:
|
||||
run_command(
|
||||
"terminus_open",
|
||||
{
|
||||
"cmd": ["ssh", "-tt", host_alias, remote_invocation],
|
||||
"cwd": str(context.local_cache_root),
|
||||
"show_in_panel": True,
|
||||
"panel_name": "Terminus",
|
||||
"auto_close": False,
|
||||
"title": "SSH {}:{}{}".format(host_alias, remote_root, title_suffix),
|
||||
"focus": True,
|
||||
},
|
||||
)
|
||||
if effective_session is not None:
|
||||
_register_terminus_view_for_session(window, effective_session, host_alias)
|
||||
else:
|
||||
ssh_shell_cmd = "ssh -tt {} {}".format(
|
||||
shlex.quote(host_alias),
|
||||
shlex.quote(remote_invocation),
|
||||
)
|
||||
run_command(
|
||||
"new_terminal",
|
||||
{
|
||||
"cmd": ssh_shell_cmd,
|
||||
},
|
||||
)
|
||||
_root._status_message(
|
||||
"Sessions terminal attached to {} {}".format(host_alias, remote_root)
|
||||
)
|
||||
|
||||
|
||||
def _session_name_belongs_to_host(session_name: str, host_alias: str) -> bool:
|
||||
"""Return ``True`` when ``session_name`` is a Sessions terminal for the host."""
|
||||
try:
|
||||
base = session_name_for_host(host_alias)
|
||||
except TerminalTmuxSessionError:
|
||||
return False
|
||||
if session_name == base:
|
||||
return True
|
||||
return session_name.startswith(base + "-")
|
||||
|
||||
|
||||
def _kill_remote_terminal_session(
|
||||
window: object, host_alias: str, session_name: str
|
||||
) -> None:
|
||||
"""Background helper for the kill command: SSH kill + close the Terminus tab."""
|
||||
|
||||
import subprocess as _subprocess
|
||||
|
||||
def work() -> None:
|
||||
try:
|
||||
completed = _root.kill_terminal_session(host_alias, session_name)
|
||||
except TerminalTmuxSessionError as exc:
|
||||
_root._set_timeout(
|
||||
lambda d=str(exc): _root._status_message(
|
||||
"Sessions: refused kill — {}".format(d)
|
||||
)
|
||||
)
|
||||
return
|
||||
except (_subprocess.TimeoutExpired, OSError) as exc:
|
||||
_root._set_timeout(
|
||||
lambda d=str(exc): _root._status_message(
|
||||
"Sessions: kill failed for {}: {}".format(session_name, d)
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
stderr = (getattr(completed, "stderr", "") or "").strip()
|
||||
rc = int(getattr(completed, "returncode", 0))
|
||||
stderr_lower = stderr.lower()
|
||||
already_gone = rc != 0 and (
|
||||
"can't find session" in stderr_lower
|
||||
or "no such session" in stderr_lower
|
||||
or "session not found" in stderr_lower
|
||||
or "no server running" in stderr_lower
|
||||
or "no sessions" in stderr_lower
|
||||
)
|
||||
|
||||
def finish() -> None:
|
||||
_close_terminus_view_for_session(window, host_alias, session_name)
|
||||
if rc == 0:
|
||||
_root._status_message(
|
||||
"Sessions: killed remote terminal {}".format(session_name)
|
||||
)
|
||||
elif already_gone:
|
||||
_root._status_message(
|
||||
"Sessions: terminal {} was already gone".format(session_name)
|
||||
)
|
||||
else:
|
||||
_root._status_message(
|
||||
"Sessions: kill {} exited {}: {}".format(session_name, rc, stderr)
|
||||
)
|
||||
|
||||
_root._set_timeout(finish, 0)
|
||||
|
||||
_root._run_in_background(
|
||||
work,
|
||||
prioritize=True,
|
||||
task_key="terminal_kill:{}:{}".format(host_alias, session_name),
|
||||
task_label="terminal_kill",
|
||||
)
|
||||
|
||||
|
||||
def _register_terminus_view_for_session(
|
||||
window: object, session_name: str, host_alias: str
|
||||
) -> None:
|
||||
"""Cache the most-recent Terminus view as the host of ``session_name``.
|
||||
|
||||
Mirrors :func:`_register_terminus_view_for_host` but keys the
|
||||
cache off the tmux session name so the kill command can close
|
||||
the matching tab regardless of which window currently owns it.
|
||||
"""
|
||||
cached_for_host = _TERMINUS_VIEW_BY_HOST.get(host_alias)
|
||||
if cached_for_host is not None and _terminus_view_is_live(cached_for_host):
|
||||
_TERMINUS_VIEW_BY_SESSION_NAME[session_name] = cached_for_host
|
||||
return
|
||||
views_fn = getattr(window, "views", None)
|
||||
if not callable(views_fn):
|
||||
return
|
||||
try:
|
||||
views = list(views_fn())
|
||||
except Exception: # pragma: no cover - defensive
|
||||
return
|
||||
for candidate in reversed(views):
|
||||
if _terminus_view_is_live(candidate):
|
||||
_TERMINUS_VIEW_BY_SESSION_NAME[session_name] = candidate
|
||||
return
|
||||
|
||||
|
||||
def _close_terminus_view_for_session(
|
||||
window: object, host_alias: str, session_name: str
|
||||
) -> None:
|
||||
"""Close the Terminus view for ``session_name`` if one is cached/live."""
|
||||
view = _TERMINUS_VIEW_BY_SESSION_NAME.pop(session_name, None)
|
||||
try:
|
||||
base = session_name_for_host(host_alias)
|
||||
except TerminalTmuxSessionError:
|
||||
base = None
|
||||
if base is not None and session_name == base:
|
||||
host_view = _TERMINUS_VIEW_BY_HOST.pop(host_alias, None)
|
||||
if view is None:
|
||||
view = host_view
|
||||
if view is None:
|
||||
# Best-effort: scan the window for a live Terminus view that
|
||||
# might be the matching tab. We only act when there is exactly
|
||||
# one candidate to avoid closing an unrelated terminal.
|
||||
view = _sole_live_terminus_view(window)
|
||||
if view is None:
|
||||
return
|
||||
close = getattr(view, "close", None)
|
||||
if callable(close):
|
||||
try:
|
||||
close()
|
||||
except Exception: # pragma: no cover - defensive
|
||||
pass
|
||||
|
||||
|
||||
def _terminus_view_is_live(view: object) -> bool:
|
||||
"""Return ``True`` when ``view`` still carries the ``terminus_view`` marker."""
|
||||
settings_fn = getattr(view, "settings", None)
|
||||
if not callable(settings_fn):
|
||||
return False
|
||||
try:
|
||||
stored = settings_fn()
|
||||
except Exception: # pragma: no cover - defensive
|
||||
return False
|
||||
if stored is None:
|
||||
return False
|
||||
getter = getattr(stored, "get", None)
|
||||
if not callable(getter):
|
||||
return False
|
||||
try:
|
||||
return bool(getter("terminus_view"))
|
||||
except Exception: # pragma: no cover - defensive
|
||||
return False
|
||||
|
||||
|
||||
def _sole_live_terminus_view(window: object) -> Optional[object]:
|
||||
"""Return the only live Terminus view in ``window``, or ``None``."""
|
||||
views_fn = getattr(window, "views", None)
|
||||
if not callable(views_fn):
|
||||
return None
|
||||
try:
|
||||
views = list(views_fn())
|
||||
except Exception: # pragma: no cover - defensive
|
||||
return None
|
||||
live = [v for v in views if _terminus_view_is_live(v)]
|
||||
if len(live) == 1:
|
||||
return live[0]
|
||||
return None
|
||||
@@ -1,25 +1,29 @@
|
||||
"""Pure-Python primitives for remote Jupyter Lab hosting.
|
||||
"""Pure-Python primitives for remote marimo notebook hosting.
|
||||
|
||||
The plugin opens ``.ipynb`` files against a remote Jupyter Lab server that
|
||||
Sessions launches on demand and keeps alive for the duration of the workspace;
|
||||
the UI runs in the user's local browser via an SSH ``-L`` tunnel. This module
|
||||
owns the server-launch / tunnel / teardown lifecycle and URL construction and
|
||||
is intentionally kept **free of Sublime imports** so the logic is unit-testable
|
||||
without the ``sublime`` runtime.
|
||||
|
||||
Companion piece ``jupyter_catalog_entry.py`` contributes the install/remove
|
||||
metadata to the managed-remote-extension catalog.
|
||||
The plugin opens ``.py`` reactive notebooks against a remote marimo edit
|
||||
server that Sessions launches on demand and keeps alive for the duration of
|
||||
the workspace; the UI runs in the user's local browser via an SSH ``-L``
|
||||
tunnel. This module owns the server-launch / tunnel / teardown lifecycle and
|
||||
URL construction and is intentionally kept **free of Sublime imports** so the
|
||||
logic is unit-testable without the ``sublime`` runtime.
|
||||
|
||||
Design notes
|
||||
------------
|
||||
- We launch the remote Jupyter server in its **own** ``ssh <alias>`` child
|
||||
- We launch the remote marimo server in its **own** ``ssh <alias>`` child
|
||||
rather than multiplexing over the existing ``local_bridge`` FSM's stdio;
|
||||
the bridge wire protocol is NDJSON framed and mixing ``jupyter lab``'s
|
||||
startup banner in would corrupt the stream.
|
||||
- Remote port is selected by Jupyter itself (``--ServerApp.port=0``); we parse
|
||||
the actual bound port out of its log file on first successful URL line.
|
||||
the bridge wire protocol is NDJSON framed and mixing marimo's startup
|
||||
banner in would corrupt the stream.
|
||||
- Remote port is selected by **us** by binding to ``127.0.0.1:0`` on the
|
||||
remote host before launch; marimo's ``--port`` flag does not document a
|
||||
``0``-means-random behaviour, so we pre-pick a free port and pass it
|
||||
explicitly. # TODO(marimo): verify that ``marimo edit --port 0`` is not
|
||||
supported, and that asking marimo for an explicit free port is the
|
||||
correct strategy (vs. parsing the bound port out of the startup log).
|
||||
- Local port is picked by binding to ``127.0.0.1:0`` and releasing — races
|
||||
are possible but acceptable for MVP.
|
||||
- No kernelspec registration: marimo uses whichever Python the ``marimo``
|
||||
CLI itself runs on, so installing marimo into the user's venv is
|
||||
sufficient — no equivalent of ``jupyter kernelspec install`` is needed.
|
||||
- Thread safety: the registry is guarded by a ``threading.Lock``. Concurrent
|
||||
``ensure_started`` calls for the same alias coalesce — only one launch
|
||||
runs.
|
||||
@@ -27,10 +31,8 @@ Design notes
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import posixpath
|
||||
import shlex
|
||||
import signal
|
||||
import socket
|
||||
@@ -53,7 +55,7 @@ except ImportError: # pragma: no cover - unit tests import without Sublime
|
||||
sublime_plugin = None # type: ignore[assignment]
|
||||
|
||||
|
||||
_LOG = logging.getLogger("sessions.jupyter_hosting")
|
||||
_LOG = logging.getLogger("sessions.marimo_hosting")
|
||||
|
||||
|
||||
def _default_run(argv: Sequence[str], **kwargs: Any) -> subprocess.CompletedProcess:
|
||||
@@ -94,7 +96,7 @@ def _shell_quote_with_tilde_expansion(arg: str) -> str:
|
||||
|
||||
# Command builder signature: given an SSH alias, return ``argv`` that prefixes
|
||||
# a remote command (e.g. ``["ssh", alias]`` or ``["ssh", "-F", config, alias]``).
|
||||
# Injected via ``JupyterSessionManager.__init__`` so tests can stub it.
|
||||
# Injected via ``MarimoSessionManager.__init__`` so tests can stub it.
|
||||
SshCommandBuilder = Callable[[str], List[str]]
|
||||
|
||||
|
||||
@@ -106,14 +108,11 @@ def _default_ssh_command_builder(alias: str) -> List[str]:
|
||||
_STARTUP_POLL_INTERVAL_SECONDS = 0.3
|
||||
|
||||
|
||||
# Cold Jupyter Lab launches on slow links / first-import-of-extensions can
|
||||
# easily take 30–60s. The previous 15s default fired before the URL line
|
||||
# was even written to the log on real-world remotes (see v0.6.11 test
|
||||
# pass: "timed out waiting for Jupyter startup … last log snippet: ''").
|
||||
# Override via the ``SESSIONS_JUPYTER_STARTUP_TIMEOUT_S`` env var when
|
||||
# Cold marimo launches on slow links / first-import-of-deps can easily take
|
||||
# 30-60s. Override via the ``SESSIONS_MARIMO_STARTUP_TIMEOUT_S`` env var when
|
||||
# tuning further on a specific host.
|
||||
def _resolve_startup_timeout_seconds() -> float:
|
||||
raw = os.environ.get("SESSIONS_JUPYTER_STARTUP_TIMEOUT_S")
|
||||
raw = os.environ.get("SESSIONS_MARIMO_STARTUP_TIMEOUT_S")
|
||||
if not raw:
|
||||
return 60.0
|
||||
try:
|
||||
@@ -129,8 +128,8 @@ _TERMINATE_GRACE_SECONDS = 2.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class JupyterServerInfo:
|
||||
"""Snapshot of one running remote Jupyter Lab server + its local tunnel."""
|
||||
class MarimoServerInfo:
|
||||
"""Snapshot of one running remote marimo edit server + its local tunnel."""
|
||||
|
||||
host_alias: str
|
||||
workspace_root: str
|
||||
@@ -140,39 +139,10 @@ class JupyterServerInfo:
|
||||
pid: int
|
||||
tunnel_pid: int
|
||||
started_at: float
|
||||
kernel_name: Optional[str] = None
|
||||
|
||||
|
||||
class JupyterHostingError(RuntimeError):
|
||||
"""Raised when a remote Jupyter server or tunnel fails to come up."""
|
||||
|
||||
|
||||
def _kernel_name_for_workspace(
|
||||
workspace_root: str,
|
||||
workspace_cache_key: Optional[str],
|
||||
) -> str:
|
||||
"""Return a stable Sessions-owned kernel name.
|
||||
|
||||
Prefers ``sessions-<cache_key[:12]>`` when a cache key is available (so one
|
||||
workspace maps to one kernel regardless of its remote path). Falls back to
|
||||
``sessions-<sha1(workspace_root)[:12]>`` so the name is still deterministic
|
||||
when callers don't have a cache key handy (ad-hoc registration flows).
|
||||
"""
|
||||
if workspace_cache_key:
|
||||
return "sessions-{}".format(workspace_cache_key[:12])
|
||||
digest = hashlib.sha1(workspace_root.encode("utf-8")).hexdigest()
|
||||
return "sessions-{}".format(digest[:12])
|
||||
|
||||
|
||||
def _is_kernelspec_already_exists(stdout: str, stderr: str) -> bool:
|
||||
"""Return True when ``jupyter kernelspec install`` refused because it exists.
|
||||
|
||||
Jupyter surfaces the collision on either stream depending on version, so
|
||||
we look at both. The message shape is stable:
|
||||
``KernelSpec <name> already exists at <path>``.
|
||||
"""
|
||||
blob = "{}\n{}".format(stdout or "", stderr or "").lower()
|
||||
return "already exists" in blob
|
||||
class MarimoHostingError(RuntimeError):
|
||||
"""Raised when a remote marimo server or tunnel fails to come up."""
|
||||
|
||||
|
||||
def _pick_free_local_port() -> int:
|
||||
@@ -183,11 +153,15 @@ def _pick_free_local_port() -> int:
|
||||
|
||||
|
||||
def _parse_remote_port_from_log(log_text: str) -> Optional[int]:
|
||||
"""Return the port Jupyter bound to, parsed from a startup log blob.
|
||||
"""Return the port marimo bound to, parsed from a startup log blob.
|
||||
|
||||
Jupyter writes lines like ``http://127.0.0.1:8889/lab?token=...`` once the
|
||||
server is ready; we grab the first such line's port. Returns ``None`` if
|
||||
no recognisable URL has been emitted yet.
|
||||
marimo writes lines like ``http://127.0.0.1:2718?access_token=...`` (or
|
||||
similar) once the edit server is ready; we grab the first such line's
|
||||
port. Returns ``None`` if no recognisable URL has been emitted yet.
|
||||
|
||||
# TODO(marimo): verify the exact startup-line format. Current parser
|
||||
# looks for ``http://127.0.0.1:<digits>`` which should match either
|
||||
# ``http://127.0.0.1:2718`` or ``http://127.0.0.1:2718/?access_token=...``.
|
||||
"""
|
||||
for raw_line in log_text.splitlines():
|
||||
line = raw_line.strip()
|
||||
@@ -196,7 +170,7 @@ def _parse_remote_port_from_log(log_text: str) -> Optional[int]:
|
||||
if idx == -1:
|
||||
continue
|
||||
tail = line[idx + len(marker) :]
|
||||
# tail looks like "8889/lab?token=..." — cut at the first non-digit.
|
||||
# tail looks like "2718/?access_token=..." — cut at first non-digit.
|
||||
digits: List[str] = []
|
||||
for ch in tail:
|
||||
if ch.isdigit():
|
||||
@@ -212,15 +186,19 @@ def _parse_remote_port_from_log(log_text: str) -> Optional[int]:
|
||||
|
||||
|
||||
def build_notebook_url(
|
||||
server: JupyterServerInfo,
|
||||
server: MarimoServerInfo,
|
||||
remote_notebook_path: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Return the tunneled Jupyter Lab URL for a server and optional notebook.
|
||||
"""Return the tunneled marimo edit URL for a server and optional notebook.
|
||||
|
||||
With ``remote_notebook_path`` inside ``server.workspace_root``, returns
|
||||
``http://127.0.0.1:<local_port>/lab/tree/<relpath>?token=<token>``. If the
|
||||
path is outside the workspace (or ``None``), falls back to plain
|
||||
``/lab?token=<token>`` and logs a note for the out-of-workspace case.
|
||||
With ``remote_notebook_path`` (an absolute remote path to a ``.py``
|
||||
reactive notebook), returns
|
||||
``http://127.0.0.1:<local_port>/?file=<abs path>&access_token=<token>``.
|
||||
Without one, falls back to the bare edit root URL.
|
||||
|
||||
# TODO(marimo): verify the exact URL shape — depending on marimo
|
||||
# version this may be ``/edit?file=...`` or ``/?file=...`` and the
|
||||
# auth query param may be ``access_token`` vs. ``token``.
|
||||
"""
|
||||
_LOG.info(
|
||||
"build_notebook_url: server.local_port=%s notebook_path=%r",
|
||||
@@ -228,32 +206,25 @@ def build_notebook_url(
|
||||
remote_notebook_path,
|
||||
)
|
||||
base = f"http://127.0.0.1:{server.local_port}"
|
||||
query = urlencode({"token": server.token})
|
||||
if remote_notebook_path is None:
|
||||
return f"{base}/lab?{query}"
|
||||
query = urlencode({"access_token": server.token})
|
||||
return f"{base}/?{query}"
|
||||
|
||||
workspace = posixpath.normpath(server.workspace_root)
|
||||
candidate = posixpath.normpath(remote_notebook_path)
|
||||
# Require candidate to sit strictly beneath workspace_root to build a
|
||||
# /lab/tree URL — otherwise Jupyter will 404 against a path outside its
|
||||
# root_dir. Equal paths are treated as "outside" (no tree path to add).
|
||||
if workspace and candidate != workspace:
|
||||
prefix = workspace.rstrip("/") + "/"
|
||||
if candidate.startswith(prefix):
|
||||
rel = candidate[len(prefix) :]
|
||||
safe_rel = quote(rel, safe="/")
|
||||
return f"{base}/lab/tree/{safe_rel}?{query}"
|
||||
|
||||
_LOG.info(
|
||||
"notebook path %r is not inside workspace_root %r; opening /lab only",
|
||||
remote_notebook_path,
|
||||
server.workspace_root,
|
||||
)
|
||||
return f"{base}/lab?{query}"
|
||||
# Pass the absolute remote path through unchanged so marimo can resolve
|
||||
# it on the remote side; ``quote`` percent-encodes spaces / unicode but
|
||||
# preserves ``/`` so the path stays human-readable in the URL.
|
||||
safe_path = quote(remote_notebook_path, safe="/")
|
||||
query = urlencode({"file": safe_path, "access_token": server.token}, safe="/")
|
||||
return f"{base}/?{query}"
|
||||
|
||||
|
||||
class JupyterSessionManager:
|
||||
"""Process-global registry of running remote Jupyter Lab servers.
|
||||
# Backwards-friendly alias so callers can ``from .marimo_hosting import
|
||||
# marimo_url_for_notebook`` if they prefer the spelled-out name.
|
||||
marimo_url_for_notebook = build_notebook_url
|
||||
|
||||
|
||||
class MarimoSessionManager:
|
||||
"""Process-global registry of running remote marimo edit servers.
|
||||
|
||||
Keyed by SSH ``host_alias``; one active server per alias at a time. Start /
|
||||
stop operations are serialised via an internal lock; ``ensure_started`` is
|
||||
@@ -304,7 +275,7 @@ class JupyterSessionManager:
|
||||
self._token_factory = token_factory or (lambda: uuid.uuid4().hex)
|
||||
|
||||
self._lock = threading.Lock()
|
||||
self._servers: Dict[str, JupyterServerInfo] = {}
|
||||
self._servers: Dict[str, MarimoServerInfo] = {}
|
||||
|
||||
@staticmethod
|
||||
def _default_connect_probe(port: int) -> None:
|
||||
@@ -314,7 +285,7 @@ class JupyterSessionManager:
|
||||
):
|
||||
return
|
||||
|
||||
def get(self, host_alias: str) -> Optional[JupyterServerInfo]:
|
||||
def get(self, host_alias: str) -> Optional[MarimoServerInfo]:
|
||||
"""Return the running server for ``host_alias`` if one is registered."""
|
||||
with self._lock:
|
||||
return self._servers.get(host_alias)
|
||||
@@ -323,38 +294,19 @@ class JupyterSessionManager:
|
||||
self,
|
||||
host_alias: str,
|
||||
workspace_root: str,
|
||||
*,
|
||||
kernel_python: Optional[str] = None,
|
||||
workspace_cache_key: Optional[str] = None,
|
||||
) -> JupyterServerInfo:
|
||||
"""Return a running Jupyter server for ``host_alias``, launching if needed.
|
||||
) -> MarimoServerInfo:
|
||||
"""Return a running marimo server for ``host_alias``, launching if needed.
|
||||
|
||||
Idempotent: if a registered server exists and its local-tunnel PID is
|
||||
still alive, that ``JupyterServerInfo`` is returned without spawning a
|
||||
still alive, that ``MarimoServerInfo`` is returned without spawning a
|
||||
new server. Concurrent calls for the same alias coalesce under the
|
||||
registry lock; only one launch runs.
|
||||
|
||||
When ``kernel_python`` is set, the manager first ensures ``ipykernel``
|
||||
is importable by that interpreter (installing it on demand via
|
||||
``pip install --user``) and registers a Sessions-owned kernelspec so
|
||||
the freshly launched Jupyter defaults to the user's interpreter rather
|
||||
than whichever Python ``jupyter lab`` itself runs on. ``workspace_cache_key``
|
||||
is used to derive a stable per-workspace kernel name; when absent the
|
||||
workspace root path is hashed instead.
|
||||
Unlike the Jupyter variant, marimo runs whichever Python it's
|
||||
installed under, so there is no kernelspec registration step — the
|
||||
caller is expected to have ensured ``marimo`` is importable in the
|
||||
target venv before invoking ``ensure_started``.
|
||||
"""
|
||||
kernel_name: Optional[str] = None
|
||||
if kernel_python:
|
||||
kernel_name = _kernel_name_for_workspace(
|
||||
workspace_root, workspace_cache_key
|
||||
)
|
||||
self._ensure_ipykernel_installed(host_alias, kernel_python)
|
||||
self._register_kernelspec(
|
||||
host_alias,
|
||||
kernel_python=kernel_python,
|
||||
kernel_name=kernel_name,
|
||||
display_name=self._default_display_name(kernel_name),
|
||||
)
|
||||
|
||||
with self._lock:
|
||||
existing = self._servers.get(host_alias)
|
||||
if existing is not None and self._tunnel_is_alive(existing.tunnel_pid):
|
||||
@@ -362,49 +314,16 @@ class JupyterSessionManager:
|
||||
# Drop a stale entry so the launch below can replace it cleanly.
|
||||
if existing is not None:
|
||||
_LOG.info(
|
||||
"dropping stale Jupyter entry for %s (tunnel pid %d gone)",
|
||||
"dropping stale marimo entry for %s (tunnel pid %d gone)",
|
||||
host_alias,
|
||||
existing.tunnel_pid,
|
||||
)
|
||||
self._servers.pop(host_alias, None)
|
||||
|
||||
info = self._launch_locked(
|
||||
host_alias,
|
||||
workspace_root,
|
||||
kernel_name=kernel_name,
|
||||
)
|
||||
info = self._launch_locked(host_alias, workspace_root)
|
||||
self._servers[host_alias] = info
|
||||
return info
|
||||
|
||||
def register_kernelspec_only(
|
||||
self,
|
||||
host_alias: str,
|
||||
kernel_python: str,
|
||||
kernel_name: str,
|
||||
*,
|
||||
display_name: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Install ``ipykernel`` (if missing) and register a kernelspec.
|
||||
|
||||
Idempotent. Safe to call repeatedly for the same ``kernel_name``; an
|
||||
existing spec is treated as success.
|
||||
"""
|
||||
if not kernel_python:
|
||||
raise JupyterHostingError(
|
||||
"register_kernelspec_only requires a non-empty kernel_python"
|
||||
)
|
||||
if not kernel_name:
|
||||
raise JupyterHostingError(
|
||||
"register_kernelspec_only requires a non-empty kernel_name"
|
||||
)
|
||||
self._ensure_ipykernel_installed(host_alias, kernel_python)
|
||||
self._register_kernelspec(
|
||||
host_alias,
|
||||
kernel_python=kernel_python,
|
||||
kernel_name=kernel_name,
|
||||
display_name=display_name or self._default_display_name(kernel_name),
|
||||
)
|
||||
|
||||
def stop(self, host_alias: str) -> None:
|
||||
"""Tear down the tunnel + remote server for ``host_alias`` (best effort)."""
|
||||
with self._lock:
|
||||
@@ -444,19 +363,16 @@ class JupyterSessionManager:
|
||||
self,
|
||||
host_alias: str,
|
||||
workspace_root: str,
|
||||
*,
|
||||
kernel_name: Optional[str] = None,
|
||||
) -> JupyterServerInfo:
|
||||
) -> MarimoServerInfo:
|
||||
token = self._token_factory()
|
||||
local_port = self._port_picker()
|
||||
log_path = f"~/.sessions/jupyter-{token}.log"
|
||||
log_path = f"~/.sessions/marimo-{token}.log"
|
||||
|
||||
remote_pid = self._spawn_remote_server(
|
||||
host_alias=host_alias,
|
||||
workspace_root=workspace_root,
|
||||
token=token,
|
||||
log_path=log_path,
|
||||
kernel_name=kernel_name,
|
||||
)
|
||||
remote_port = self._await_remote_port(
|
||||
host_alias=host_alias,
|
||||
@@ -477,11 +393,11 @@ class JupyterSessionManager:
|
||||
remote_pid=remote_pid,
|
||||
log_path=log_path,
|
||||
)
|
||||
raise JupyterHostingError(
|
||||
raise MarimoHostingError(
|
||||
f"local tunnel probe on 127.0.0.1:{local_port} failed: {exc}"
|
||||
) from exc
|
||||
|
||||
return JupyterServerInfo(
|
||||
return MarimoServerInfo(
|
||||
host_alias=host_alias,
|
||||
workspace_root=workspace_root,
|
||||
remote_port=remote_port,
|
||||
@@ -490,7 +406,6 @@ class JupyterSessionManager:
|
||||
pid=remote_pid,
|
||||
tunnel_pid=tunnel_pid,
|
||||
started_at=self._clock(),
|
||||
kernel_name=kernel_name,
|
||||
)
|
||||
|
||||
def _spawn_remote_server(
|
||||
@@ -500,42 +415,44 @@ class JupyterSessionManager:
|
||||
workspace_root: str,
|
||||
token: str,
|
||||
log_path: str,
|
||||
kernel_name: Optional[str] = None,
|
||||
) -> int:
|
||||
kernel_arg = ""
|
||||
if kernel_name:
|
||||
# Quote the kernel name defensively so weird characters never
|
||||
# break out of the remote shell command even though our generator
|
||||
# only emits ``sessions-<hex12>``.
|
||||
kernel_arg = " --MappingKernelManager.default_kernel_name=" + shlex.quote(
|
||||
kernel_name
|
||||
)
|
||||
# marimo's CLI does not document a ``--port 0`` behaviour, so we ask
|
||||
# the remote shell to pick a free port via Python's stdlib (binding
|
||||
# to :0 then closing) and pass that integer to ``marimo edit``.
|
||||
# # TODO(marimo): verify whether ``marimo edit --port 0`` is in
|
||||
# fact unsupported — if it picks a free port itself, simplify this
|
||||
# to ``--port 0`` and parse the actual bound port from the log
|
||||
# (the `_await_remote_port` helper already handles that).
|
||||
port_pick_py = (
|
||||
'python3 -c \'import socket; s=socket.socket(); s.bind(("127.0.0.1",0)); '
|
||||
"print(s.getsockname()[1]); s.close()'"
|
||||
)
|
||||
# Build the remote launch script. Notes:
|
||||
# - ``--headless`` keeps marimo from trying to open a browser on the
|
||||
# remote host.
|
||||
# - ``--token-password`` (or equivalent) supplies our generated
|
||||
# shared secret; we do NOT pass ``--no-token``.
|
||||
# - ``--host 127.0.0.1`` so the SSH ``-L`` tunnel is the only path in.
|
||||
# # TODO(marimo): verify the exact flag spelling. Recent marimo
|
||||
# versions use ``--token-password <token>`` while older ones used
|
||||
# ``--token <token>``; some versions also gate edit-server auth
|
||||
# behind ``--no-token`` / ``--token-password=<>`` semantics.
|
||||
remote_script = (
|
||||
"mkdir -p ~/.sessions && "
|
||||
f"nohup jupyter lab --no-browser "
|
||||
f"--ServerApp.ip=127.0.0.1 --ServerApp.port=0 "
|
||||
f"--ServerApp.token={token} "
|
||||
f"--ServerApp.root_dir={workspace_root}"
|
||||
f"{kernel_arg} "
|
||||
f"cd {shlex.quote(workspace_root)} && "
|
||||
f"PORT=$({port_pick_py}) && "
|
||||
f"nohup marimo edit --headless --host 127.0.0.1 "
|
||||
f'--port "$PORT" --token-password {shlex.quote(token)} '
|
||||
f"> {log_path} 2>&1 & echo $!"
|
||||
)
|
||||
# Pass ``bash -lc <script>`` as a single SSH-side argument so the
|
||||
# remote login shell doesn't tokenise the script and pass only
|
||||
# the leading word to ``bash -lc``. The previous form
|
||||
# (``["bash", "-lc", remote_script]``) was joined by SSH with
|
||||
# spaces, after which the remote shell saw ``bash -lc mkdir
|
||||
# -p ~/.sessions && nohup jupyter ...`` — bash got just
|
||||
# ``mkdir`` as its script (with ``-p`` / ``~/.sessions`` as
|
||||
# positional args ignored by the no-arg ``mkdir`` body), the
|
||||
# subsequent ``&&`` short-circuited on mkdir's failure, and
|
||||
# the redirect that should have created the log file was
|
||||
# never reached. Symptom from the v0.6.12 test pass: 60s
|
||||
# timeout with ``cat: ~/.sessions/jupyter-<token>.log:
|
||||
# No such file or directory``.
|
||||
# remote login shell doesn't tokenise the script and pass only the
|
||||
# leading word to ``bash -lc``. (See jupyter_hosting.py for the full
|
||||
# postmortem of the prior tokenisation bug.)
|
||||
argv = list(self._ssh(host_alias)) + [
|
||||
"bash -lc " + shlex.quote(remote_script),
|
||||
]
|
||||
_LOG.debug("spawning remote jupyter on %s: %s", host_alias, argv)
|
||||
_LOG.debug("spawning remote marimo on %s: %s", host_alias, argv)
|
||||
completed = self._run(
|
||||
argv,
|
||||
stdout=subprocess.PIPE,
|
||||
@@ -544,167 +461,23 @@ class JupyterSessionManager:
|
||||
text=True,
|
||||
)
|
||||
if completed.returncode != 0:
|
||||
raise JupyterHostingError(
|
||||
f"remote jupyter launch on {host_alias} exited "
|
||||
raise MarimoHostingError(
|
||||
f"remote marimo launch on {host_alias} exited "
|
||||
f"{completed.returncode}: {completed.stderr!r}"
|
||||
)
|
||||
pid_text = (completed.stdout or "").strip().splitlines()
|
||||
if not pid_text:
|
||||
raise JupyterHostingError(
|
||||
f"remote jupyter launch on {host_alias} produced no PID output"
|
||||
raise MarimoHostingError(
|
||||
f"remote marimo launch on {host_alias} produced no PID output"
|
||||
)
|
||||
try:
|
||||
return int(pid_text[-1].strip())
|
||||
except ValueError as exc:
|
||||
raise JupyterHostingError(
|
||||
f"remote jupyter launch on {host_alias} returned non-numeric "
|
||||
raise MarimoHostingError(
|
||||
f"remote marimo launch on {host_alias} returned non-numeric "
|
||||
f"PID: {pid_text!r}"
|
||||
) from exc
|
||||
|
||||
@staticmethod
|
||||
def _default_display_name(kernel_name: str) -> str:
|
||||
"""Return the Sessions-branded display name for a kernel name.
|
||||
|
||||
Trims the ``sessions-`` prefix so the label that Jupyter Lab renders
|
||||
stays terse (the full 12-char hash is visible on hover).
|
||||
"""
|
||||
short = kernel_name
|
||||
if short.startswith("sessions-"):
|
||||
short = short[len("sessions-") :]
|
||||
return "Sessions {}".format(short)
|
||||
|
||||
def _ensure_ipykernel_installed(
|
||||
self,
|
||||
host_alias: str,
|
||||
kernel_python: str,
|
||||
) -> None:
|
||||
"""Ensure ``ipykernel`` is importable via ``kernel_python``.
|
||||
|
||||
``uv`` creates venvs without ``pip`` by default, so the first install
|
||||
attempt often fails with ``No module named pip``. On that specific
|
||||
failure we try ``python -m ensurepip --upgrade --default-pip`` and
|
||||
retry. ``--user`` is **not** passed: most active Python choices are
|
||||
venvs, where ``--user`` bypasses the venv and installs to user-site
|
||||
— precisely the opposite of what the user wants.
|
||||
|
||||
``pip install`` exits 0 when the package is already satisfied, so no
|
||||
special-case handling is required for the already-installed path.
|
||||
"""
|
||||
pip_argv = [
|
||||
kernel_python,
|
||||
"-m",
|
||||
"pip",
|
||||
"install",
|
||||
"--quiet",
|
||||
"ipykernel",
|
||||
]
|
||||
completed = self._run_over_ssh(host_alias, pip_argv)
|
||||
if completed.returncode == 0:
|
||||
return
|
||||
|
||||
if self._stderr_mentions_missing_pip(completed.stderr):
|
||||
# Bootstrap pip into the venv, then retry. ``ensurepip`` is part
|
||||
# of the Python standard library, so uv venvs that ship without
|
||||
# ``pip`` still have it unless the creator trimmed the stdlib.
|
||||
ensurepip = self._run_over_ssh(
|
||||
host_alias,
|
||||
[kernel_python, "-m", "ensurepip", "--upgrade", "--default-pip"],
|
||||
)
|
||||
if ensurepip.returncode != 0:
|
||||
raise JupyterHostingError(
|
||||
f"ensurepip bootstrap via {kernel_python} on {host_alias} "
|
||||
f"exited {ensurepip.returncode}: "
|
||||
f"stderr={(ensurepip.stderr or '').strip()!r}"
|
||||
)
|
||||
completed = self._run_over_ssh(host_alias, pip_argv)
|
||||
|
||||
if completed.returncode != 0:
|
||||
raise JupyterHostingError(
|
||||
f"ipykernel install via {kernel_python} on {host_alias} "
|
||||
f"exited {completed.returncode}: "
|
||||
f"stdout={(completed.stdout or '').strip()!r} "
|
||||
f"stderr={(completed.stderr or '').strip()!r}"
|
||||
)
|
||||
|
||||
def _run_over_ssh(
|
||||
self,
|
||||
host_alias: str,
|
||||
argv: list,
|
||||
) -> subprocess.CompletedProcess:
|
||||
"""Shell-quote ``argv`` and run as one remote command under ``ssh <alias>``.
|
||||
|
||||
OpenSSH concatenates any trailing positional arguments with single
|
||||
spaces before handing the resulting string to the remote shell for
|
||||
re-parsing. That means arguments that legitimately contain spaces
|
||||
(``--display-name "Sessions abc"``) are torn apart on the remote side
|
||||
and misread by argparse. Quoting every arg with :func:`shlex.quote`
|
||||
and passing the whole command as a single trailing SSH arg defeats
|
||||
that split.
|
||||
|
||||
Also handles ``~/`` tilde paths: ``shlex.quote`` single-quotes the
|
||||
whole arg which prevents the remote shell from expanding ``~``, and
|
||||
zsh / bash refuse a literal ``~`` as a path component. Rewrite the
|
||||
leading ``~/`` as ``"$HOME"/...`` so the unquoted ``$HOME`` expands
|
||||
while the suffix stays safely quoted.
|
||||
"""
|
||||
remote_cmd = " ".join(_shell_quote_with_tilde_expansion(a) for a in argv)
|
||||
full = list(self._ssh(host_alias)) + [remote_cmd]
|
||||
return self._run(
|
||||
full,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
check=False,
|
||||
text=True,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _stderr_mentions_missing_pip(stderr: Optional[str]) -> bool:
|
||||
return "No module named pip" in (stderr or "")
|
||||
|
||||
def _register_kernelspec(
|
||||
self,
|
||||
host_alias: str,
|
||||
*,
|
||||
kernel_python: str,
|
||||
kernel_name: str,
|
||||
display_name: str,
|
||||
) -> None:
|
||||
"""Register a Sessions-owned kernelspec pointing at ``kernel_python``.
|
||||
|
||||
Idempotent: if ``jupyter kernelspec install`` refuses because the spec
|
||||
is already present (either via "already exists" message or non-zero
|
||||
exit carrying that message), the call is treated as success.
|
||||
"""
|
||||
completed = self._run_over_ssh(
|
||||
host_alias,
|
||||
[
|
||||
kernel_python,
|
||||
"-m",
|
||||
"ipykernel",
|
||||
"install",
|
||||
"--user",
|
||||
"--name",
|
||||
kernel_name,
|
||||
"--display-name",
|
||||
display_name,
|
||||
],
|
||||
)
|
||||
if completed.returncode == 0:
|
||||
return
|
||||
if _is_kernelspec_already_exists(completed.stdout, completed.stderr):
|
||||
_LOG.info(
|
||||
"kernelspec %s already exists on %s; reusing",
|
||||
kernel_name,
|
||||
host_alias,
|
||||
)
|
||||
return
|
||||
raise JupyterHostingError(
|
||||
f"kernelspec install {kernel_name} on {host_alias} exited "
|
||||
f"{completed.returncode}: "
|
||||
f"stdout={(completed.stdout or '').strip()!r} "
|
||||
f"stderr={(completed.stderr or '').strip()!r}"
|
||||
)
|
||||
|
||||
def _await_remote_port(
|
||||
self,
|
||||
*,
|
||||
@@ -732,9 +505,9 @@ class JupyterSessionManager:
|
||||
return port
|
||||
else:
|
||||
# `cat` returned non-zero — file likely doesn't exist yet
|
||||
# (jupyter still booting and hasn't redirected its first
|
||||
# (marimo still booting and hasn't redirected its first
|
||||
# write). Capture stderr so the timeout error doesn't
|
||||
# surface as the unhelpful "last log snippet: ''".
|
||||
# surface as an unhelpful empty-snippet message.
|
||||
last_stderr = (completed.stderr or "").strip()
|
||||
self._sleep(_STARTUP_POLL_INTERVAL_SECONDS)
|
||||
if last_text:
|
||||
@@ -742,9 +515,9 @@ class JupyterSessionManager:
|
||||
elif last_stderr:
|
||||
tail = "(log file unreadable, ssh stderr: {})".format(last_stderr)
|
||||
else:
|
||||
tail = "(empty — jupyter wrote nothing within timeout)"
|
||||
raise JupyterHostingError(
|
||||
"timed out after {timeout:.0f}s waiting for Jupyter startup on "
|
||||
tail = "(empty — marimo wrote nothing within timeout)"
|
||||
raise MarimoHostingError(
|
||||
"timed out after {timeout:.0f}s waiting for marimo startup on "
|
||||
"{host}; last cat rc={rc}; log snippet: {tail!r}".format(
|
||||
timeout=_STARTUP_TIMEOUT_SECONDS,
|
||||
host=host_alias,
|
||||
@@ -771,17 +544,17 @@ class JupyterSessionManager:
|
||||
)
|
||||
pid = getattr(proc, "pid", None)
|
||||
if pid is None:
|
||||
raise JupyterHostingError(
|
||||
raise MarimoHostingError(
|
||||
f"local ssh tunnel for {host_alias} did not report a PID"
|
||||
)
|
||||
return int(pid)
|
||||
|
||||
def _teardown(self, info: JupyterServerInfo) -> None:
|
||||
def _teardown(self, info: MarimoServerInfo) -> None:
|
||||
self._teardown_pids(
|
||||
host_alias=info.host_alias,
|
||||
tunnel_pid=info.tunnel_pid,
|
||||
remote_pid=info.pid,
|
||||
log_path=f"~/.sessions/jupyter-{info.token}.log",
|
||||
log_path=f"~/.sessions/marimo-{info.token}.log",
|
||||
)
|
||||
|
||||
def _teardown_pids(
|
||||
@@ -132,7 +132,6 @@ class RemoteCacheMirrorResult:
|
||||
|
||||
|
||||
MIRROR_BUILTIN_IGNORE_PATTERNS: Tuple[str, ...] = (
|
||||
".git",
|
||||
"node_modules",
|
||||
"__pycache__",
|
||||
".venv",
|
||||
|
||||
@@ -1,816 +0,0 @@
|
||||
"""VSCode-style hover-activated links in Sessions-spawned Terminus buffers.
|
||||
|
||||
When the user hovers the mouse over a token in a Terminus terminal
|
||||
rendered by Sessions we:
|
||||
|
||||
- classify the token under the cursor via :func:`classify_terminal_token`
|
||||
+ :func:`extract_token_at` (both pure and load-bearing; do not touch);
|
||||
- paint a ``"markup.underline.link"`` region under the token so the user
|
||||
can *see* the link before clicking (VSCode / modern-editor idiom);
|
||||
- on the next hover that moves off the token, erase the region.
|
||||
|
||||
A Cmd+click (macOS) / Ctrl+click (Win/Linux) on an active region fires
|
||||
the matching handler:
|
||||
|
||||
- URL (``https://…``, ``http://…``, ``ftp://…``, ``file://…``) opens in
|
||||
the user's default browser via :mod:`webbrowser`.
|
||||
- Absolute remote path (``/srv/app/pkg/a.py``) routes through the
|
||||
existing ``SessionsOnDemandFetchListener`` via
|
||||
``window.run_command("open_file", …)`` — that listener maps the path
|
||||
onto a workspace cache entry, fetches if missing, then opens.
|
||||
- Relative path (``pkg/a.py``) is resolved against the active window's
|
||||
workspace mirror via :class:`RemoteToLocalCacheMapper` and only
|
||||
underlined / opened when the local cache file already exists (i.e.
|
||||
the user is hovering over a file the mirror already materialized).
|
||||
|
||||
The v0.4.18 design filtered *all* ``drag_select`` clicks by modifier
|
||||
but gave the user no visible affordance for which tokens were
|
||||
clickable. The hover-activation UX solves that: the cursor reveals
|
||||
links the same way VSCode does, and the existing click intercept
|
||||
short-circuits to the active region if hover already classified the
|
||||
token (so we never pay for two classifications on one click). The
|
||||
intercept path still works without hover (falls back to on-click
|
||||
classification) for environments where ``on_hover`` doesn't fire.
|
||||
|
||||
Hover state is per-view and lives in a module-level dict keyed by
|
||||
``view.id()``. ``on_close`` clears the entry so the dict doesn't grow
|
||||
unbounded across a long Sublime session. We never hold a reference to
|
||||
the ``view`` object itself — only the int id — to avoid retaining
|
||||
closed views.
|
||||
|
||||
Line:col suffix (grep -n style ``/path/to/file.py:42:7``) is still
|
||||
discarded by ``classify_terminal_token`` for now; the file opens at
|
||||
position 0 once the fetch-then-open listener threads encoded positions
|
||||
through the async flow (separate follow-up).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import webbrowser
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Mapping, Optional, Tuple
|
||||
|
||||
try:
|
||||
import sublime_plugin # type: ignore
|
||||
|
||||
import sublime # type: ignore
|
||||
except ImportError: # pragma: no cover - unit tests import without Sublime
|
||||
sublime = None # type: ignore[assignment]
|
||||
sublime_plugin = None # type: ignore[assignment]
|
||||
|
||||
|
||||
# Caveat: link scope can render as a box (theme-dependent, not a bug) and Sublime's ~1s ``on_hover_delay_ms`` is the expected dwell. # noqa: E501
|
||||
|
||||
_LOG = logging.getLogger("sessions.terminal_link")
|
||||
|
||||
|
||||
# RFC-ish URL shape — stops at whitespace, control chars, common quotes and
|
||||
# terminal-line gutter characters so "See http://example.com." picks up
|
||||
# "http://example.com" not ".com.".
|
||||
_URL_PATTERN = re.compile(
|
||||
r"^(https?|ftp|file)://[^\s<>\"'`\\]+$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Scheme-less ``host:port[/path]`` shape that is conventionally addressable
|
||||
# in a browser as ``http://host:port/path``. Matches the localhost dev-server
|
||||
# case (``localhost:8080``, ``127.0.0.1:5173``) and explicit IPv4 + port that
|
||||
# Jupyter / FastAPI / Vite etc. log to the terminal. We deliberately exclude
|
||||
# IPv6, hostnames with dots (those belong in the scheme'd ``http(s)://``
|
||||
# form), and bare ``host`` with no port (too noisy — ``var:42`` would match).
|
||||
_HOST_PORT_PATTERN = re.compile(
|
||||
r"^(?P<host>localhost|127\.0\.0\.1|\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})"
|
||||
r":(?P<port>\d{1,5})"
|
||||
r"(?P<rest>/[^\s<>\"'`\\]*)?$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Absolute POSIX path with optional ``:line`` and ``:line:col`` tail
|
||||
# (grep -n, compiler error, Python traceback formats all match).
|
||||
_ABSPATH_WITH_POS_PATTERN = re.compile(
|
||||
r"^(?P<path>/[^\s:]+(?:[^\s]*?))"
|
||||
r"(?::(?P<line>\d+)(?::(?P<col>\d+))?)?$",
|
||||
)
|
||||
|
||||
# Relative POSIX path candidate. We accept ``./x``, ``../x``, ``x/y/z`` and
|
||||
# bare basenames (``a.py``) — but only mark them clickable when the active
|
||||
# window can resolve them inside its workspace cache mirror (see
|
||||
# :func:`_resolve_relpath_in_cache`). The shape is intentionally permissive;
|
||||
# the existence check on the local cache is what disambiguates true filenames
|
||||
# from arbitrary CLI output.
|
||||
_RELPATH_PATTERN = re.compile(
|
||||
r"^(?:\.{1,2}/)?[A-Za-z0-9_\-+@%][A-Za-z0-9_\-+@%./]*$",
|
||||
)
|
||||
|
||||
# ANSI / VT100 control sequence stripper. Terminus normally renders ANSI as
|
||||
# colour and never returns escape bytes from ``view.substr``; M1 (a) reports
|
||||
# coloured ``ls`` output where escapes *do* leak into the visible text (e.g.
|
||||
# scroll-back replay edge cases or a user-customised ``ls`` profile that
|
||||
# embeds raw escapes). Stripping at the classifier entry-point is cheap and
|
||||
# makes the abspath / URL match path robust regardless of which way Terminus
|
||||
# went.
|
||||
_ANSI_ESCAPE_PATTERN = re.compile(
|
||||
r"\x1b\[[0-9;?]*[ -/]*[@-~]"
|
||||
r"|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)"
|
||||
r"|\x1b[@-_]"
|
||||
)
|
||||
|
||||
|
||||
def _strip_ansi(text: str) -> str:
|
||||
"""Remove ANSI / VT100 escape sequences from ``text``.
|
||||
|
||||
Used defensively at the classifier entry-point so coloured ``ls``
|
||||
output (or any other escape-bearing token) classifies the same as
|
||||
its plain-text twin. Returning the same string when there is no
|
||||
match keeps the common case allocation-free.
|
||||
"""
|
||||
if "\x1b" not in text:
|
||||
return text
|
||||
return _ANSI_ESCAPE_PATTERN.sub("", text)
|
||||
|
||||
|
||||
# Region key under which we paint the active hover-link underline. Sublime's
|
||||
# ``add_regions`` silently replaces an existing region that shares the same
|
||||
# key, so a single key per view is all we need — each new hover overwrites
|
||||
# the prior one.
|
||||
_HOVER_REGION_KEY = "sessions_terminal_link"
|
||||
|
||||
# Scope selected for the link underline. Sublime resolves this to the
|
||||
# ``link`` colour from the user's colour scheme, mirroring how builtin
|
||||
# Markdown / docstring links are styled.
|
||||
_HOVER_REGION_SCOPE = "markup.underline.link"
|
||||
|
||||
|
||||
def classify_terminal_token(token: str) -> Optional[Tuple[str, str]]:
|
||||
"""Return ``("url", token)`` / ``("abspath", remote_path)`` or ``None``.
|
||||
|
||||
The line/col suffix is currently stripped and discarded so the downstream
|
||||
``open_file`` path gets a clean filename. When we thread encoded
|
||||
positions through the on-demand-fetch listener this returns a
|
||||
``("abspath", "/path/to/file.py", line, col)`` shape instead.
|
||||
"""
|
||||
if not token:
|
||||
return None
|
||||
# Strip ANSI escapes *first* so a coloured ``ls`` line classifies the
|
||||
# same as plain ``ls`` output. M1 (a) reproduced the case where colour
|
||||
# escapes leak through Terminus' substr and break the abspath regex.
|
||||
token = _strip_ansi(token)
|
||||
# Trim only trailing sentence punctuation — leading "." / ":" / etc.
|
||||
# discriminate between valid tokens we should reject (``./rel/path``
|
||||
# isn't an absolute path) and accidental full-stops (``https://x.com.``
|
||||
# picked up from prose). Leading bracket/paren stripping is off for
|
||||
# the same reason; if the user wants ``(http://x)`` we ask them to
|
||||
# click on the URL itself.
|
||||
token = token.strip().rstrip(",.;:)]}>\"'`")
|
||||
if not token:
|
||||
return None
|
||||
if _URL_PATTERN.match(token):
|
||||
return ("url", token)
|
||||
# Scheme-less ``localhost:8080`` / ``127.0.0.1:5173/foo`` get auto-
|
||||
# promoted to ``http://...`` so the browser can resolve them. We do
|
||||
# this *before* the absolute-path test because ``/foo`` paths only
|
||||
# start with ``/`` and never have a host:port shape.
|
||||
host_port = _HOST_PORT_PATTERN.match(token)
|
||||
if host_port:
|
||||
port_str = host_port.group("port")
|
||||
try:
|
||||
port_value = int(port_str)
|
||||
except ValueError:
|
||||
port_value = -1
|
||||
if 0 < port_value <= 65535:
|
||||
host = host_port.group("host")
|
||||
rest = host_port.group("rest") or ""
|
||||
# ``0.0.0.0`` is the wildcard bind address servers print to
|
||||
# signal "listening on every interface" — macOS browsers
|
||||
# refuse to route to it (Safari/Chrome land on
|
||||
# ``about:blank`` with a stray suffix). Canonicalize to
|
||||
# ``localhost`` so the click reaches the loopback listener
|
||||
# the user actually wants. ``127.0.0.1`` already resolves on
|
||||
# every platform so we leave it alone.
|
||||
if host == "0.0.0.0":
|
||||
host = "localhost"
|
||||
# macOS ``open location`` (driving Safari/Chrome through
|
||||
# AppleScript) treats a host:port URL with no path as
|
||||
# under-specified and falls back to ``about:blank`` plus a
|
||||
# leftover token. Always emit a canonical trailing slash
|
||||
# when no path was present so every platform sees a
|
||||
# well-formed ``http://host:port/`` URL.
|
||||
if not rest:
|
||||
rest = "/"
|
||||
return ("url", "http://" + host + ":" + port_str + rest)
|
||||
match = _ABSPATH_WITH_POS_PATTERN.match(token)
|
||||
if match:
|
||||
path = match.group("path")
|
||||
if path and path != "/":
|
||||
return ("abspath", path)
|
||||
return None
|
||||
|
||||
|
||||
def extract_token_at(view: object, point: int) -> Optional[str]:
|
||||
"""Return the whitespace-delimited token surrounding ``point`` in ``view``.
|
||||
|
||||
Terminal output is whitespace-separated in the common case; we expand
|
||||
left and right from ``point`` until a whitespace character. Empty
|
||||
result → ``None``.
|
||||
"""
|
||||
line_fn = getattr(view, "line", None)
|
||||
substr_fn = getattr(view, "substr", None)
|
||||
if not callable(line_fn) or not callable(substr_fn):
|
||||
return None
|
||||
line_region = line_fn(point)
|
||||
try:
|
||||
line_start = line_region.begin()
|
||||
except AttributeError:
|
||||
return None
|
||||
line_text = substr_fn(line_region)
|
||||
if not isinstance(line_text, str):
|
||||
return None
|
||||
rel = point - line_start
|
||||
if rel < 0:
|
||||
rel = 0
|
||||
if rel > len(line_text):
|
||||
rel = len(line_text)
|
||||
# Expand left
|
||||
left = rel
|
||||
while left > 0 and not line_text[left - 1].isspace():
|
||||
left -= 1
|
||||
# Expand right
|
||||
right = rel
|
||||
while right < len(line_text) and not line_text[right].isspace():
|
||||
right += 1
|
||||
token = line_text[left:right]
|
||||
return token or None
|
||||
|
||||
|
||||
def _token_span_at(view: object, point: int) -> Optional[Tuple[int, int, str]]:
|
||||
"""Return ``(start, end, token)`` for the token under ``point``.
|
||||
|
||||
Parallel to :func:`extract_token_at` but also returns the absolute
|
||||
character offsets so the caller can paint a region over the exact
|
||||
span. Returns ``None`` when ``point`` lies between two spaces or the
|
||||
view lacks the required API.
|
||||
"""
|
||||
line_fn = getattr(view, "line", None)
|
||||
substr_fn = getattr(view, "substr", None)
|
||||
if not callable(line_fn) or not callable(substr_fn):
|
||||
return None
|
||||
line_region = line_fn(point)
|
||||
try:
|
||||
line_start = line_region.begin()
|
||||
except AttributeError:
|
||||
return None
|
||||
line_text = substr_fn(line_region)
|
||||
if not isinstance(line_text, str):
|
||||
return None
|
||||
rel = point - line_start
|
||||
if rel < 0:
|
||||
rel = 0
|
||||
if rel > len(line_text):
|
||||
rel = len(line_text)
|
||||
left = rel
|
||||
while left > 0 and not line_text[left - 1].isspace():
|
||||
left -= 1
|
||||
right = rel
|
||||
while right < len(line_text) and not line_text[right].isspace():
|
||||
right += 1
|
||||
if left == right:
|
||||
return None
|
||||
token = line_text[left:right]
|
||||
if not token:
|
||||
return None
|
||||
return (line_start + left, line_start + right, token)
|
||||
|
||||
|
||||
def _is_terminus_view(view: object) -> bool:
|
||||
"""Return True if ``view`` is a Terminus terminal buffer."""
|
||||
settings_fn = getattr(view, "settings", None)
|
||||
if not callable(settings_fn):
|
||||
return False
|
||||
try:
|
||||
settings = settings_fn()
|
||||
except Exception: # pragma: no cover - defensive
|
||||
return False
|
||||
get = getattr(settings, "get", None)
|
||||
if not callable(get):
|
||||
return False
|
||||
return bool(get("terminus_view"))
|
||||
|
||||
|
||||
def _primary_modifier_held(event: Mapping[str, Any]) -> bool:
|
||||
"""Return True if Cmd (macOS) / Ctrl (Win/Linux) was down for this event."""
|
||||
modifiers = event.get("modifier_keys") if isinstance(event, Mapping) else None
|
||||
if not isinstance(modifiers, Mapping):
|
||||
return False
|
||||
return bool(modifiers.get("primary"))
|
||||
|
||||
|
||||
def _point_from_event(view: object, event: Mapping[str, Any]) -> Optional[int]:
|
||||
x = event.get("x") if isinstance(event, Mapping) else None
|
||||
y = event.get("y") if isinstance(event, Mapping) else None
|
||||
if x is None or y is None:
|
||||
return None
|
||||
window_to_text = getattr(view, "window_to_text", None)
|
||||
if not callable(window_to_text):
|
||||
return None
|
||||
try:
|
||||
return int(window_to_text((x, y)))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _handle_url(url: str) -> None:
|
||||
try:
|
||||
webbrowser.open(url)
|
||||
except Exception: # pragma: no cover - best-effort link open
|
||||
pass
|
||||
|
||||
|
||||
def _handle_abspath(window: object, remote_path: str) -> None:
|
||||
run_command = getattr(window, "run_command", None)
|
||||
if not callable(run_command):
|
||||
return
|
||||
# The existing ``SessionsOnDemandFetchListener.on_window_command``
|
||||
# intercepts ``open_file`` whose ``file`` is an absolute POSIX path
|
||||
# and either reuses the workspace cache entry or routes via
|
||||
# ``__extern/``, fetching if the local copy isn't materialized yet.
|
||||
run_command("open_file", {"file": remote_path})
|
||||
|
||||
|
||||
def _handle_local_path(window: object, local_path: str) -> None:
|
||||
"""Open a path that already exists in the local cache mirror."""
|
||||
run_command = getattr(window, "run_command", None)
|
||||
if not callable(run_command):
|
||||
return
|
||||
# The local cache file is real on disk — Sublime's ``open_file`` is
|
||||
# enough; ``SessionsOnDemandFetchListener`` notices the file exists
|
||||
# and falls through. We deliberately don't translate back to a remote
|
||||
# path here so the open happens immediately rather than re-routing
|
||||
# through the fetch path.
|
||||
run_command("open_file", {"file": local_path})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Relative-path resolution against the active workspace cache mirror
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _window_for_view(view: object) -> Optional[object]:
|
||||
"""Return the Sublime window owning ``view`` or ``None``."""
|
||||
window_fn = getattr(view, "window", None)
|
||||
if not callable(window_fn):
|
||||
return None
|
||||
try:
|
||||
return window_fn()
|
||||
except Exception: # pragma: no cover - defensive
|
||||
return None
|
||||
|
||||
|
||||
def _workspace_mirror_for_window(
|
||||
window: object,
|
||||
) -> Optional[Tuple["object", str, Path]]:
|
||||
"""Return ``(mapper, remote_root, files_cache_root)`` for ``window``.
|
||||
|
||||
Reuses the existing workspace-context resolver in :mod:`sessions.commands`
|
||||
rather than reinventing the workspace-key / recent-entry plumbing here.
|
||||
The import is kept lazy so unit tests that don't touch the workspace
|
||||
pathway never pay for it. Returns ``None`` when:
|
||||
|
||||
- the import fails (Sublime not present, ``sessions.commands`` not yet
|
||||
registered as a plugin module);
|
||||
- no workspace metadata is wired to ``window`` (e.g. plain Sublime
|
||||
window with no ``sessions_workspace_key`` project setting).
|
||||
"""
|
||||
if window is None:
|
||||
return None
|
||||
try:
|
||||
from . import commands as _commands_mod
|
||||
from .file_state import RemoteToLocalCacheMapper
|
||||
from .settings_model import SessionsSettings
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
_LOG.debug(
|
||||
"terminal_link.mirror_lookup_import_failed", extra={"error": repr(exc)}
|
||||
)
|
||||
return None
|
||||
workspace_context = getattr(_commands_mod, "_workspace_context", None)
|
||||
if not callable(workspace_context):
|
||||
return None
|
||||
try:
|
||||
context = workspace_context(
|
||||
window, SessionsSettings(), missing_detail_message=False
|
||||
)
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
_LOG.debug("terminal_link.mirror_lookup_failed", extra={"error": repr(exc)})
|
||||
return None
|
||||
if context is None:
|
||||
return None
|
||||
recent_entry = getattr(context, "recent_entry", None)
|
||||
remote_root = getattr(recent_entry, "remote_root", None)
|
||||
cache_root = getattr(context, "local_cache_root", None)
|
||||
cache_key = getattr(context, "cache_key", None)
|
||||
if not (isinstance(remote_root, str) and isinstance(cache_root, Path)):
|
||||
return None
|
||||
if not isinstance(cache_key, str):
|
||||
return None
|
||||
mapper = RemoteToLocalCacheMapper(
|
||||
workspace_cache_key=cache_key,
|
||||
remote_workspace_root=remote_root,
|
||||
files_cache_root=cache_root,
|
||||
)
|
||||
return (mapper, remote_root, cache_root)
|
||||
|
||||
|
||||
def _resolve_relpath_in_cache(view: object, token: str) -> Optional[Tuple[str, str]]:
|
||||
"""Resolve ``token`` (a relative-looking path) against the local mirror.
|
||||
|
||||
Returns ``("relpath", local_cache_path_str)`` when the token resolves to
|
||||
a *file* that already exists under the workspace cache, else ``None``.
|
||||
Directories don't promote — a click should open a file. Tokens that
|
||||
don't match :data:`_RELPATH_PATTERN` (URLs, abspaths, junk) short-
|
||||
circuit immediately.
|
||||
"""
|
||||
if not token or token.startswith("/"):
|
||||
return None
|
||||
if not _RELPATH_PATTERN.match(token):
|
||||
return None
|
||||
window = _window_for_view(view)
|
||||
if window is None:
|
||||
return None
|
||||
mirror = _workspace_mirror_for_window(window)
|
||||
if mirror is None:
|
||||
return None
|
||||
mapper, remote_root, _cache_root = mirror
|
||||
# Strip ``./`` prefix; ``..`` paths are rejected to keep the resolver
|
||||
# within the workspace tree (matches the existing
|
||||
# ``RemoteToLocalCacheMapper`` safety contract).
|
||||
rel = token[2:] if token.startswith("./") else token
|
||||
if rel.startswith("../") or rel == ".." or "/.." in rel or rel.startswith(".."):
|
||||
return None
|
||||
remote_path = remote_root.rstrip("/") + "/" + rel
|
||||
try:
|
||||
local_path = mapper.local_path_for_remote_file(remote_path)
|
||||
except Exception as exc:
|
||||
_LOG.debug(
|
||||
"terminal_link.relpath_map_failed",
|
||||
extra={"token": token, "error": repr(exc)},
|
||||
)
|
||||
return None
|
||||
try:
|
||||
if not local_path.is_file():
|
||||
return None
|
||||
except OSError:
|
||||
return None
|
||||
return ("relpath", str(local_path))
|
||||
|
||||
|
||||
def classify_with_context(view: object, token: str) -> Optional[Tuple[str, str]]:
|
||||
"""Classify ``token`` including the relative-path-against-mirror case.
|
||||
|
||||
Pure ``classify_terminal_token`` covers URLs and absolute paths and
|
||||
is safe to call without a view; this wrapper layers on the cache-
|
||||
aware ``("relpath", local_path)`` shape so the hover/click sites
|
||||
can use a single decision point.
|
||||
"""
|
||||
primary = classify_terminal_token(token)
|
||||
if primary is not None:
|
||||
return primary
|
||||
return _resolve_relpath_in_cache(view, token)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hover state cache
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# ``view.id()`` → ``(start, end, kind, value)``. ``kind`` is one of
|
||||
# ``"url"`` / ``"abspath"`` — mirrors the tuple shape of
|
||||
# ``classify_terminal_token``. Stored as a plain dict rather than a
|
||||
# ``WeakValueDictionary`` because the values are primitives, not objects
|
||||
# with identity; ``on_close`` is the drop hook.
|
||||
_HOVER_STATE: Dict[int, Tuple[int, int, str, str]] = {}
|
||||
|
||||
|
||||
def _clear_hover_region(view: object) -> None:
|
||||
"""Erase the hover-link region painted on ``view``."""
|
||||
erase = getattr(view, "erase_regions", None)
|
||||
if callable(erase):
|
||||
try:
|
||||
erase(_HOVER_REGION_KEY)
|
||||
except Exception: # pragma: no cover - defensive; Sublime raises rarely
|
||||
pass
|
||||
|
||||
|
||||
def _paint_hover_region(view: object, start: int, end: int) -> None:
|
||||
"""Paint the hover-link underline region on ``view``.
|
||||
|
||||
Uses ``DRAW_NO_FILL | DRAW_SOLID_UNDERLINE`` so the token stays
|
||||
readable; the colour comes from the ``link`` scope resolved against
|
||||
the active colour scheme.
|
||||
"""
|
||||
add_regions = getattr(view, "add_regions", None)
|
||||
if not callable(add_regions):
|
||||
return
|
||||
flags = 0
|
||||
if sublime is not None:
|
||||
draw_no_fill = getattr(sublime, "DRAW_NO_FILL", 0)
|
||||
draw_underline = getattr(sublime, "DRAW_SOLID_UNDERLINE", 0)
|
||||
flags = int(draw_no_fill) | int(draw_underline)
|
||||
# Construct a ``sublime.Region`` when available, otherwise fall back
|
||||
# to a plain tuple for unit tests — the FakeView in ``conftest``
|
||||
# accepts any iterable of regions.
|
||||
if sublime is not None:
|
||||
region_ctor = getattr(sublime, "Region", None)
|
||||
if callable(region_ctor):
|
||||
region = region_ctor(start, end)
|
||||
else:
|
||||
region = (start, end)
|
||||
else:
|
||||
region = (start, end)
|
||||
try:
|
||||
add_regions(
|
||||
_HOVER_REGION_KEY,
|
||||
[region],
|
||||
_HOVER_REGION_SCOPE,
|
||||
"",
|
||||
flags,
|
||||
)
|
||||
except Exception: # pragma: no cover - defensive; Sublime raises rarely
|
||||
pass
|
||||
|
||||
|
||||
def _view_id(view: object) -> Optional[int]:
|
||||
id_fn = getattr(view, "id", None)
|
||||
if not callable(id_fn):
|
||||
return None
|
||||
try:
|
||||
value = id_fn()
|
||||
except Exception: # pragma: no cover - defensive
|
||||
return None
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _record_hover(
|
||||
view: object,
|
||||
start: int,
|
||||
end: int,
|
||||
kind: str,
|
||||
value: str,
|
||||
) -> None:
|
||||
"""Persist the active hover link span so click can re-use it."""
|
||||
vid = _view_id(view)
|
||||
if vid is None:
|
||||
return
|
||||
_HOVER_STATE[vid] = (start, end, kind, value)
|
||||
|
||||
|
||||
def _active_hover_for_point(
|
||||
view: object,
|
||||
point: int,
|
||||
) -> Optional[Tuple[int, int, str, str]]:
|
||||
"""Return the recorded hover tuple if ``point`` falls inside its span."""
|
||||
vid = _view_id(view)
|
||||
if vid is None:
|
||||
return None
|
||||
entry = _HOVER_STATE.get(vid)
|
||||
if entry is None:
|
||||
return None
|
||||
start, end, _kind, _value = entry
|
||||
if start <= point < end:
|
||||
return entry
|
||||
return None
|
||||
|
||||
|
||||
def _drop_hover(view: object) -> None:
|
||||
"""Drop the per-view hover state + erase any painted region."""
|
||||
vid = _view_id(view)
|
||||
if vid is not None:
|
||||
_HOVER_STATE.pop(vid, None)
|
||||
_clear_hover_region(view)
|
||||
|
||||
|
||||
def _hover_log_extra(
|
||||
view: object,
|
||||
point: int,
|
||||
hover_zone: int,
|
||||
matched_kind: Optional[str],
|
||||
matched_text: Optional[str],
|
||||
) -> Dict[str, Any]:
|
||||
"""Build the structured ``extra`` payload for ``terminal_link.hover``."""
|
||||
return {
|
||||
"hover_zone": hover_zone,
|
||||
"point": point,
|
||||
"terminus_view": _is_terminus_view(view),
|
||||
"matched_kind": matched_kind,
|
||||
"matched_text": matched_text,
|
||||
}
|
||||
|
||||
|
||||
def process_hover(
|
||||
view: object,
|
||||
point: int,
|
||||
hover_zone: int,
|
||||
) -> Optional[Tuple[str, str]]:
|
||||
"""Handle a single hover event; paint / erase regions as needed.
|
||||
|
||||
Returns the ``(kind, value)`` tuple when a link was activated, else
|
||||
``None``. Tests exercise this directly to avoid instantiating the
|
||||
full ``EventListener`` under the Sublime stub.
|
||||
|
||||
Every decision point logs ``terminal_link.hover`` so the M1 (b)
|
||||
instrumentation captures *why* a hover didn't paint as well as the
|
||||
success cases.
|
||||
"""
|
||||
if sublime is not None:
|
||||
hover_text = getattr(sublime, "HOVER_TEXT", 1)
|
||||
else:
|
||||
hover_text = 1
|
||||
if hover_zone != hover_text:
|
||||
_drop_hover(view)
|
||||
_LOG.debug(
|
||||
"terminal_link.hover.skip_non_text_zone",
|
||||
extra=_hover_log_extra(view, point, hover_zone, None, None),
|
||||
)
|
||||
return None
|
||||
if not _is_terminus_view(view):
|
||||
_drop_hover(view)
|
||||
_LOG.debug(
|
||||
"terminal_link.hover.skip_non_terminus",
|
||||
extra=_hover_log_extra(view, point, hover_zone, None, None),
|
||||
)
|
||||
return None
|
||||
span = _token_span_at(view, point)
|
||||
if span is None:
|
||||
_drop_hover(view)
|
||||
_LOG.debug(
|
||||
"terminal_link.hover.no_token",
|
||||
extra=_hover_log_extra(view, point, hover_zone, None, None),
|
||||
)
|
||||
return None
|
||||
start, end, token = span
|
||||
classified = classify_with_context(view, token)
|
||||
if classified is None:
|
||||
_drop_hover(view)
|
||||
_LOG.debug(
|
||||
"terminal_link.hover.unmatched",
|
||||
extra=_hover_log_extra(view, point, hover_zone, None, token),
|
||||
)
|
||||
return None
|
||||
kind, value = classified
|
||||
_paint_hover_region(view, start, end)
|
||||
_record_hover(view, start, end, kind, value)
|
||||
_LOG.info(
|
||||
"terminal_link.hover.matched",
|
||||
extra=_hover_log_extra(view, point, hover_zone, kind, value),
|
||||
)
|
||||
return (kind, value)
|
||||
|
||||
|
||||
_EventListenerBase = (
|
||||
sublime_plugin.EventListener if sublime_plugin is not None else object
|
||||
)
|
||||
|
||||
|
||||
class SessionsTerminalLinkClickListener(_EventListenerBase): # type: ignore[misc]
|
||||
"""Underline links on hover + dispatch Cmd-clicks in Terminus views.
|
||||
|
||||
Sublime wires this in via ``plugin.py`` at load time. Unit tests
|
||||
exercise :func:`classify_terminal_token`, :func:`extract_token_at`,
|
||||
and :func:`process_hover` directly without needing Sublime's API,
|
||||
so we keep the base class a plain ``object`` stub when
|
||||
``sublime_plugin`` isn't importable.
|
||||
"""
|
||||
|
||||
def on_hover(
|
||||
self,
|
||||
view: object,
|
||||
point: int,
|
||||
hover_zone: int,
|
||||
) -> None:
|
||||
"""Activate / deactivate the underline on mouse hover."""
|
||||
process_hover(view, point, hover_zone)
|
||||
|
||||
def on_close(self, view: object) -> None:
|
||||
"""Drop per-view hover state when the Terminus pane closes."""
|
||||
_drop_hover(view)
|
||||
|
||||
def on_text_command(
|
||||
self,
|
||||
view: object,
|
||||
command_name: str,
|
||||
args: Optional[Mapping[str, Any]],
|
||||
) -> Optional[Tuple[str, Mapping[str, Any]]]:
|
||||
"""Route primary-modifier ``drag_select`` clicks to URL/path handlers.
|
||||
|
||||
Returning ``("noop", {})`` when we successfully dispatch the link
|
||||
suppresses the underlying ``drag_select`` so Sublime / Terminus
|
||||
don't *also* move the caret + forward a raw mouse-click into the
|
||||
terminal. Without this suppression the v0.5.x click regression
|
||||
manifests: hover paints the box, but Cmd+click runs ``drag_select``
|
||||
first, which mutates selection / cursor in the Terminus pane and
|
||||
ends up swallowing the open. Returning ``None`` everywhere else
|
||||
keeps normal text selection working when no link is under the
|
||||
cursor.
|
||||
"""
|
||||
if command_name != "drag_select":
|
||||
return None
|
||||
if not _is_terminus_view(view):
|
||||
return None
|
||||
if not isinstance(args, Mapping):
|
||||
return None
|
||||
event = args.get("event")
|
||||
if not isinstance(event, Mapping):
|
||||
return None
|
||||
if not _primary_modifier_held(event):
|
||||
return None
|
||||
point = _point_from_event(view, event)
|
||||
if point is None:
|
||||
return None
|
||||
# Fast path: hover already classified the token under the
|
||||
# cursor; re-use that decision rather than re-running the token
|
||||
# extractor + classifier on every click.
|
||||
active = _active_hover_for_point(view, point)
|
||||
source = "hover_cache"
|
||||
if active is not None:
|
||||
_start, _end, kind, value = active
|
||||
else:
|
||||
source = "reclassify"
|
||||
token = extract_token_at(view, point)
|
||||
if token is None:
|
||||
_LOG.debug(
|
||||
"terminal_link.click.no_token",
|
||||
extra={"point": point},
|
||||
)
|
||||
return None
|
||||
classified = classify_with_context(view, token)
|
||||
if classified is None:
|
||||
_LOG.debug(
|
||||
"terminal_link.click.unmatched",
|
||||
extra={"point": point, "token": token},
|
||||
)
|
||||
return None
|
||||
kind, value = classified
|
||||
window = _window_for_view(view)
|
||||
if kind == "url":
|
||||
_LOG.info(
|
||||
"terminal_link.click",
|
||||
extra={
|
||||
"matched_kind": kind,
|
||||
"matched_text": value,
|
||||
"resolved_target": value,
|
||||
"action": "open_browser",
|
||||
"outcome": "dispatched",
|
||||
"source": source,
|
||||
},
|
||||
)
|
||||
_handle_url(value)
|
||||
return ("noop", {})
|
||||
if kind == "abspath" and window is not None:
|
||||
_LOG.info(
|
||||
"terminal_link.click",
|
||||
extra={
|
||||
"matched_kind": kind,
|
||||
"matched_text": value,
|
||||
"resolved_target": value,
|
||||
"action": "open_file_remote",
|
||||
"outcome": "dispatched_to_open_file",
|
||||
"source": source,
|
||||
},
|
||||
)
|
||||
_handle_abspath(window, value)
|
||||
return ("noop", {})
|
||||
if kind == "relpath" and window is not None:
|
||||
_LOG.info(
|
||||
"terminal_link.click",
|
||||
extra={
|
||||
"matched_kind": kind,
|
||||
"matched_text": value,
|
||||
"resolved_target": value,
|
||||
"action": "open_file_local_cache",
|
||||
"outcome": "dispatched_to_open_file",
|
||||
"source": source,
|
||||
},
|
||||
)
|
||||
_handle_local_path(window, value)
|
||||
return ("noop", {})
|
||||
_LOG.debug(
|
||||
"terminal_link.click.no_dispatch",
|
||||
extra={
|
||||
"matched_kind": kind,
|
||||
"matched_text": value,
|
||||
"has_window": window is not None,
|
||||
"source": source,
|
||||
},
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
__all__ = (
|
||||
"SessionsTerminalLinkClickListener",
|
||||
"classify_terminal_token",
|
||||
"classify_with_context",
|
||||
"extract_token_at",
|
||||
"process_hover",
|
||||
)
|
||||
@@ -1,707 +0,0 @@
|
||||
"""Tmux-session helpers for ``Sessions: Open Remote Terminal`` (Track C2).
|
||||
|
||||
The remote-terminal command wraps its SSH invocation in
|
||||
``tmux new-session -A -s <name>`` so that closing and re-opening the
|
||||
Terminus tab reattaches to the same shell (history + running processes
|
||||
preserved). This module owns:
|
||||
|
||||
- **Session-name construction** — canonicalises a ``host_alias`` into
|
||||
``sessions-term-<sanitized-alias>``. The name must be safe to embed in
|
||||
a shell command built by the command entry-point; we validate against
|
||||
a tight charset and reject aliases that would require escaping.
|
||||
- **Tmux availability probe** — runs ``command -v tmux`` on the remote
|
||||
host with a short timeout. The caller falls back to the previous
|
||||
direct-shell spawn when tmux is missing, and uses the probe result as
|
||||
a one-shot hint for the first terminal launch per host.
|
||||
|
||||
The ``sessions-term-`` prefix intentionally differs from the
|
||||
``sessions-agent-`` prefix owned by ``agent_tmux.py`` (Track D). Both
|
||||
prefixes share the ``sessions-`` root for easy user-side enumeration
|
||||
(``tmux list-sessions | grep ^sessions-``) while staying partitioned so
|
||||
closing a terminal view never touches an agent session.
|
||||
|
||||
Nothing here imports from ``sublime``; the integrator wires this module
|
||||
into the Sublime command separately so unit tests stay subprocess-free.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable, Iterable, List, Literal, Optional, Sequence
|
||||
|
||||
from .ssh_runner import _subprocess_no_window_kwargs
|
||||
|
||||
_LOG = logging.getLogger(__name__)
|
||||
|
||||
# Supported close modes for :func:`close_terminal_session`. The three modes
|
||||
# share the SSH transport and the session-name validation; they differ in
|
||||
# whether the remote tmux session survives and in which UX hook fires.
|
||||
#
|
||||
# * ``"detach"`` — default close path. The Terminus tab disappears but the
|
||||
# tmux session keeps running on the remote so the next
|
||||
# ``Open Remote Terminal`` reattaches with shell history + running
|
||||
# processes intact. The helper performs no SSH call in this mode; it
|
||||
# only validates the inputs and reports a synthetic "no-op" outcome so
|
||||
# callers don't need to special-case it.
|
||||
# * ``"plain"`` — default-on-pane-close *non*-persistent variant. Behaves
|
||||
# like ``"kill"`` for tmux (the session is destroyed) but is the
|
||||
# implicit close path, so any UX hooks that should only fire when the
|
||||
# user explicitly asked to kill must check ``kind`` and skip ``"plain"``.
|
||||
# * ``"kill"`` — explicit user action via the ``Sessions: Kill Remote
|
||||
# Terminal`` palette command. Same SSH effect as ``"plain"``; differs
|
||||
# only in intent so call sites can render the right status message and
|
||||
# any explicit-kill UX hooks fire.
|
||||
TerminalCloseKind = Literal["detach", "kill", "plain"]
|
||||
TERMINAL_CLOSE_KINDS: tuple = ("detach", "kill", "plain")
|
||||
|
||||
# Allowed values for the ``sessions_terminal_close_default`` Sublime setting.
|
||||
# ``"detach"`` preserves the v0.6.x default behavior; ``"plain"`` opts the
|
||||
# user into "implicit close kills the session" — useful when a pane should
|
||||
# never outlive its tab.
|
||||
TERMINAL_CLOSE_DEFAULT_VALUES: tuple = ("detach", "plain")
|
||||
DEFAULT_TERMINAL_CLOSE_DEFAULT: Literal["detach", "plain"] = "detach"
|
||||
|
||||
# Hosts in ``~/.ssh/config`` commonly contain alphanumerics plus ``._-``.
|
||||
# The validator intentionally rejects anything else (spaces, shell meta,
|
||||
# wildcards, non-ASCII) so the resulting session name is always safe to
|
||||
# shlex-quote without needing additional escaping passes. Uppercase is
|
||||
# accepted because OpenSSH preserves case in the alias and tmux session
|
||||
# names are case-sensitive.
|
||||
_HOST_ALIAS_RE = re.compile(r"\A[A-Za-z0-9._-]+\Z")
|
||||
|
||||
# Dedicated prefix for Sessions-owned remote *terminal* tmux sessions.
|
||||
# Distinct from ``sessions-agent-`` (Track D / ``agent_tmux.py``) so
|
||||
# terminal and agent sessions can coexist on the same host without
|
||||
# ever aliasing each other's state.
|
||||
SESSION_NAME_PREFIX = "sessions-term-"
|
||||
|
||||
|
||||
# Run callable signature: mirror ``subprocess.run`` well enough for the
|
||||
# small subset we use. Tests inject a recorder so the probe stays hermetic.
|
||||
RunFn = Callable[..., "subprocess.CompletedProcess[str]"]
|
||||
|
||||
|
||||
class TerminalTmuxSessionError(ValueError):
|
||||
"""Raised for a ``host_alias`` that cannot be rendered safely.
|
||||
|
||||
Subclasses :class:`ValueError` so callers that only care about the
|
||||
"bad input" contract can ``except ValueError`` without knowing this
|
||||
module.
|
||||
"""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TmuxProbeResult:
|
||||
"""Outcome of probing ``command -v tmux`` on a remote host.
|
||||
|
||||
``available`` is the boolean decision used by the integrator. The
|
||||
other fields are kept for diagnostics and to let the caller render
|
||||
a helpful status hint when tmux is missing.
|
||||
"""
|
||||
|
||||
available: bool
|
||||
exit_code: int
|
||||
stdout: str
|
||||
stderr: str
|
||||
|
||||
|
||||
def session_name_for_host(host_alias: str) -> str:
|
||||
"""Return the canonical tmux session name for a ``host_alias``.
|
||||
|
||||
The output has the form ``sessions-term-<alias>`` and is safe to
|
||||
embed verbatim in a shell command built by the caller. Invalid
|
||||
aliases raise :class:`TerminalTmuxSessionError` *before* any
|
||||
subprocess call is issued.
|
||||
|
||||
Args:
|
||||
host_alias: SSH config alias (for example ``prod`` or
|
||||
``bastion.example.com``).
|
||||
|
||||
Returns:
|
||||
The canonical session name, e.g. ``sessions-term-prod``.
|
||||
|
||||
Raises:
|
||||
TerminalTmuxSessionError: When ``host_alias`` is empty or
|
||||
contains characters outside ``[A-Za-z0-9._-]``.
|
||||
"""
|
||||
if not isinstance(host_alias, str) or not host_alias:
|
||||
raise TerminalTmuxSessionError("host_alias must be a non-empty string")
|
||||
if not _HOST_ALIAS_RE.match(host_alias):
|
||||
raise TerminalTmuxSessionError(
|
||||
"host_alias contains disallowed characters: {!r}".format(host_alias)
|
||||
)
|
||||
return "{}{}".format(SESSION_NAME_PREFIX, host_alias)
|
||||
|
||||
|
||||
def next_terminal_session_name(host_alias: str, existing_names: Iterable[str]) -> str:
|
||||
"""Return the next free numbered session name for ``host_alias``.
|
||||
|
||||
The base session ``sessions-term-<alias>`` is the persistent
|
||||
"main" terminal owned by ``Sessions: Open Remote Terminal``.
|
||||
Additional panes use numeric suffixes starting at ``-2``:
|
||||
``sessions-term-<alias>-2``, ``sessions-term-<alias>-3``, ... .
|
||||
The function scans ``existing_names`` for the host's prefix and
|
||||
returns the smallest free index >= 2. The first numbered pane
|
||||
(index 2) is preferred when nothing in ``existing_names`` yet
|
||||
matches a numbered slot for this host even if the base session
|
||||
is already running — the base session is reserved for the
|
||||
default reattach command.
|
||||
|
||||
Args:
|
||||
host_alias: SSH config alias (validated against the same
|
||||
charset used by :func:`session_name_for_host`).
|
||||
existing_names: Iterable of tmux session names already
|
||||
running on the host; typically ``list_terminal_sessions``
|
||||
output but any iterable of strings works.
|
||||
|
||||
Returns:
|
||||
The next free numbered session name.
|
||||
|
||||
Raises:
|
||||
TerminalTmuxSessionError: When ``host_alias`` fails the same
|
||||
validation as :func:`session_name_for_host`.
|
||||
"""
|
||||
base = session_name_for_host(host_alias)
|
||||
used: set[int] = set()
|
||||
numbered_prefix = base + "-"
|
||||
for raw in existing_names:
|
||||
if not isinstance(raw, str):
|
||||
continue
|
||||
if not raw.startswith(numbered_prefix):
|
||||
continue
|
||||
suffix = raw[len(numbered_prefix) :]
|
||||
if not suffix.isdigit():
|
||||
continue
|
||||
try:
|
||||
value = int(suffix)
|
||||
except ValueError: # pragma: no cover - guarded by isdigit
|
||||
continue
|
||||
if value >= 2:
|
||||
used.add(value)
|
||||
candidate = 2
|
||||
while candidate in used:
|
||||
candidate += 1
|
||||
return "{}-{}".format(base, candidate)
|
||||
|
||||
|
||||
def list_all_remote_tmux_sessions(
|
||||
host_alias: str,
|
||||
*,
|
||||
ssh_command_builder: Optional[Callable[[str], Sequence[str]]] = None,
|
||||
run: Optional[RunFn] = None,
|
||||
timeout: float = 10.0,
|
||||
) -> List[str]:
|
||||
"""Return EVERY tmux session name on the remote, including foreign ones.
|
||||
|
||||
Sibling of :func:`list_terminal_sessions`, but does **not** filter
|
||||
by :data:`SESSION_NAME_PREFIX`. Used by
|
||||
``Sessions: Attach to Tmux Session`` so the user can attach a
|
||||
Terminus pane to a tmux session they started outside Sessions
|
||||
(typed ``tmux new -s work`` directly, or attached to a session
|
||||
created by another tool).
|
||||
|
||||
Same forgiving error semantics as ``list_terminal_sessions`` — the
|
||||
three "no sessions to report" cases (no server running / tmux
|
||||
missing / SSH probe error) all collapse to ``[]`` so the caller
|
||||
doesn't have to special-case them when populating a quick panel.
|
||||
|
||||
Args:
|
||||
host_alias: SSH alias to enumerate against.
|
||||
ssh_command_builder: Maps ``alias`` to an argv prefix; defaults
|
||||
to ``["ssh", alias]``.
|
||||
run: Override for ``subprocess.run``.
|
||||
timeout: Seconds before giving up.
|
||||
|
||||
Returns:
|
||||
Every session name reported by ``tmux list-sessions``, in
|
||||
the order tmux returned them. Empty when the server isn't
|
||||
running, tmux isn't installed, or the SSH probe failed.
|
||||
"""
|
||||
builder = ssh_command_builder or _default_ssh_command_builder
|
||||
run_fn = run or subprocess.run
|
||||
# Pass the remote command as a single pre-quoted string so SSH
|
||||
# forwards it verbatim to the remote shell. If we passed
|
||||
# ``["tmux", "list-sessions", "-F", "#{session_name}"]`` as
|
||||
# separate argv entries, OpenSSH joins them with spaces and the
|
||||
# remote shell sees ``tmux list-sessions -F #{session_name}`` —
|
||||
# ``#`` then starts a comment and tmux gets ``-F`` with no value
|
||||
# (``command list-sessions: -F expects an argument``). Quoting
|
||||
# the format string at the local-side argv prevents the remote
|
||||
# shell from chewing on the ``#``.
|
||||
argv: List[str] = list(builder(host_alias)) + [
|
||||
"tmux list-sessions -F " + shlex.quote("#{session_name}"),
|
||||
]
|
||||
try:
|
||||
completed = run_fn(
|
||||
argv,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
timeout=timeout,
|
||||
check=False,
|
||||
text=True,
|
||||
**_subprocess_no_window_kwargs(),
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
_LOG.warning(
|
||||
"tmux list-sessions on %s timed out after %.1fs",
|
||||
host_alias,
|
||||
timeout,
|
||||
)
|
||||
return []
|
||||
except OSError as exc:
|
||||
_LOG.warning(
|
||||
"tmux list-sessions on %s failed to spawn ssh: %s",
|
||||
host_alias,
|
||||
exc,
|
||||
)
|
||||
return []
|
||||
if completed.returncode != 0:
|
||||
# Distinguish "no tmux server" (rc=1, "no server running") from
|
||||
# genuine SSH/tmux errors so users hitting an empty quick panel
|
||||
# can see WHY in the console instead of being told there's
|
||||
# nothing remote when there is.
|
||||
stderr_tail = (completed.stderr or "").strip()
|
||||
if stderr_tail and "no server running" not in stderr_tail.lower():
|
||||
_LOG.warning(
|
||||
"tmux list-sessions on %s exited %d: stderr=%r",
|
||||
host_alias,
|
||||
completed.returncode,
|
||||
stderr_tail[-400:],
|
||||
)
|
||||
return []
|
||||
return [
|
||||
line.strip() for line in (completed.stdout or "").splitlines() if line.strip()
|
||||
]
|
||||
|
||||
|
||||
def list_terminal_sessions(
|
||||
host_alias: str,
|
||||
*,
|
||||
ssh_command_builder: Optional[Callable[[str], Sequence[str]]] = None,
|
||||
run: Optional[RunFn] = None,
|
||||
timeout: float = 10.0,
|
||||
) -> List[str]:
|
||||
"""Return Sessions-owned remote terminal tmux session names.
|
||||
|
||||
Runs ``tmux list-sessions -F '#{session_name}'`` on the remote
|
||||
host and filters the output down to names starting with
|
||||
:data:`SESSION_NAME_PREFIX`. Three "normal" non-error paths
|
||||
return the empty list instead of raising:
|
||||
|
||||
* tmux reports "no server running" / "no sessions";
|
||||
* tmux is not installed (exit 127 / "command not found");
|
||||
* the SSH probe itself times out or hits an ``OSError``.
|
||||
|
||||
The caller drives kill / next-pane decisions off this output, so
|
||||
swallowing the empty cases keeps those flows dead-simple.
|
||||
|
||||
Args:
|
||||
host_alias: SSH alias to enumerate against.
|
||||
ssh_command_builder: Maps ``alias`` to an argv prefix for
|
||||
remote commands. Defaults to ``["ssh", alias]``.
|
||||
run: Override for ``subprocess.run``. Tests pass a recorder.
|
||||
timeout: Seconds before giving up.
|
||||
|
||||
Returns:
|
||||
Session names belonging to Sessions terminals — both the
|
||||
base ``sessions-term-<alias>`` and any numbered children.
|
||||
"""
|
||||
builder = ssh_command_builder or _default_ssh_command_builder
|
||||
run_fn = run or subprocess.run
|
||||
# Same SSH-quoting reasoning as ``list_all_remote_tmux_sessions``:
|
||||
# the ``-F '#{session_name}'`` format string must be passed to
|
||||
# SSH as a single pre-quoted argument, otherwise the remote shell
|
||||
# treats the unquoted ``#`` as a comment marker and tmux receives
|
||||
# ``-F`` with no value.
|
||||
argv: List[str] = list(builder(host_alias)) + [
|
||||
"tmux list-sessions -F " + shlex.quote("#{session_name}"),
|
||||
]
|
||||
try:
|
||||
completed = run_fn(
|
||||
argv,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
timeout=timeout,
|
||||
check=False,
|
||||
text=True,
|
||||
**_subprocess_no_window_kwargs(),
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
_LOG.warning(
|
||||
"tmux list-sessions on %s timed out after %.1fs",
|
||||
host_alias,
|
||||
timeout,
|
||||
)
|
||||
return []
|
||||
except OSError as exc:
|
||||
_LOG.warning(
|
||||
"tmux list-sessions on %s failed to spawn ssh: %s",
|
||||
host_alias,
|
||||
exc,
|
||||
)
|
||||
return []
|
||||
if completed.returncode == 0:
|
||||
return [
|
||||
line.strip()
|
||||
for line in (completed.stdout or "").splitlines()
|
||||
if line.strip().startswith(SESSION_NAME_PREFIX)
|
||||
]
|
||||
# tmux exits 1 with "no server running" / "no sessions" when the
|
||||
# tmux server hasn't been started. 127 / "command not found" when
|
||||
# the binary isn't installed. In either case we have no terminal
|
||||
# sessions to report. Surface anything else so the empty-list
|
||||
# surprise has a diagnosable trail in the console.
|
||||
stderr_tail = (completed.stderr or "").strip()
|
||||
if stderr_tail and "no server running" not in stderr_tail.lower():
|
||||
_LOG.warning(
|
||||
"tmux list-sessions on %s exited %d: stderr=%r",
|
||||
host_alias,
|
||||
completed.returncode,
|
||||
stderr_tail[-400:],
|
||||
)
|
||||
return []
|
||||
|
||||
|
||||
def kill_terminal_session(
|
||||
host_alias: str,
|
||||
session_name: str,
|
||||
*,
|
||||
ssh_command_builder: Optional[Callable[[str], Sequence[str]]] = None,
|
||||
run: Optional[RunFn] = None,
|
||||
timeout: float = 10.0,
|
||||
) -> "subprocess.CompletedProcess[str]":
|
||||
"""Run ``tmux kill-session -t <session_name>`` on ``host_alias``.
|
||||
|
||||
Returns the completed process so callers can surface stderr in a
|
||||
status hint when the session was already gone (a zero-cost
|
||||
common case after the user manually exited the shell).
|
||||
|
||||
Args:
|
||||
host_alias: SSH alias to target.
|
||||
session_name: tmux session name to kill. Must start with
|
||||
:data:`SESSION_NAME_PREFIX`; otherwise the call refuses
|
||||
so a misuse can never reach into agent or unrelated
|
||||
tmux sessions.
|
||||
ssh_command_builder: Argv-prefix builder; defaults to
|
||||
``["ssh", alias]``.
|
||||
run: Override for ``subprocess.run``.
|
||||
timeout: Seconds before giving up.
|
||||
|
||||
Returns:
|
||||
The :class:`subprocess.CompletedProcess` from the remote
|
||||
tmux invocation.
|
||||
|
||||
Raises:
|
||||
TerminalTmuxSessionError: When ``session_name`` does not
|
||||
belong to the Sessions terminal namespace.
|
||||
"""
|
||||
if not isinstance(session_name, str) or not session_name.startswith(
|
||||
SESSION_NAME_PREFIX
|
||||
):
|
||||
raise TerminalTmuxSessionError(
|
||||
"refusing to kill non-terminal tmux session: {!r}".format(session_name)
|
||||
)
|
||||
builder = ssh_command_builder or _default_ssh_command_builder
|
||||
run_fn = run or subprocess.run
|
||||
argv: List[str] = list(builder(host_alias)) + [
|
||||
"tmux",
|
||||
"kill-session",
|
||||
"-t",
|
||||
session_name,
|
||||
]
|
||||
return run_fn(
|
||||
argv,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
timeout=timeout,
|
||||
check=False,
|
||||
text=True,
|
||||
**_subprocess_no_window_kwargs(),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TerminalCloseOutcome:
|
||||
"""Outcome of :func:`close_terminal_session`.
|
||||
|
||||
``kind`` echoes the close mode the caller requested. ``executed``
|
||||
records whether the helper actually issued an SSH call: ``False`` for
|
||||
the no-op ``"detach"`` mode and ``True`` for ``"kill"`` /
|
||||
``"plain"``. ``completed`` is the underlying
|
||||
:class:`subprocess.CompletedProcess` when an SSH call ran, else
|
||||
``None``.
|
||||
|
||||
The shape lets a single caller branch on ``executed`` to render a
|
||||
status message (or skip cleanup entirely on detach) without
|
||||
re-implementing per-mode dispatch.
|
||||
"""
|
||||
|
||||
kind: TerminalCloseKind
|
||||
executed: bool
|
||||
completed: Optional["subprocess.CompletedProcess[str]"] = None
|
||||
|
||||
|
||||
def close_terminal_session(
|
||||
host_alias: str,
|
||||
session_name: str,
|
||||
*,
|
||||
kind: TerminalCloseKind = "detach",
|
||||
ssh_command_builder: Optional[Callable[[str], Sequence[str]]] = None,
|
||||
run: Optional[RunFn] = None,
|
||||
timeout: float = 10.0,
|
||||
) -> TerminalCloseOutcome:
|
||||
"""Close a remote terminal tmux session in one of three modes.
|
||||
|
||||
Single entry point for the three close paths so call sites don't need
|
||||
to know that ``"detach"`` is a no-op in this module while ``"kill"``
|
||||
and ``"plain"`` collapse onto the same SSH command:
|
||||
|
||||
* ``"detach"`` — leaves the remote tmux session running; the next
|
||||
``Open Remote Terminal`` reattaches. Issues no SSH call. This is
|
||||
the historical default close behavior.
|
||||
* ``"plain"`` — runs ``tmux kill-session -t <session_name>`` over SSH
|
||||
so the session does *not* persist. Intended as the
|
||||
default-on-pane-close path when the user has set
|
||||
``sessions_terminal_close_default`` to ``"plain"``; UX hooks that
|
||||
should only fire on explicit user action must skip this mode.
|
||||
* ``"kill"`` — same SSH effect as ``"plain"`` but signals an
|
||||
explicit user action (palette command). Status messages and
|
||||
explicit-kill UX hooks should fire only on this mode.
|
||||
|
||||
The session-name guard from :func:`kill_terminal_session` is reused
|
||||
so ``"plain"`` / ``"kill"`` can never reach into agent or unrelated
|
||||
tmux sessions.
|
||||
|
||||
Args:
|
||||
host_alias: SSH alias to target. Not validated here; the caller
|
||||
is expected to pass a known-good value.
|
||||
session_name: tmux session name. Must start with
|
||||
:data:`SESSION_NAME_PREFIX` for ``"plain"`` and ``"kill"``;
|
||||
for ``"detach"`` the prefix is still required so a misuse
|
||||
doesn't silently slip past the contract.
|
||||
kind: One of :data:`TERMINAL_CLOSE_KINDS`.
|
||||
ssh_command_builder: Argv-prefix builder; defaults to
|
||||
``["ssh", alias]``.
|
||||
run: Override for ``subprocess.run``.
|
||||
timeout: Seconds before giving up on the SSH call (only relevant
|
||||
when ``kind`` is ``"plain"`` or ``"kill"``).
|
||||
|
||||
Returns:
|
||||
A :class:`TerminalCloseOutcome` describing what ran.
|
||||
|
||||
Raises:
|
||||
TerminalTmuxSessionError: When ``session_name`` does not belong
|
||||
to the Sessions terminal namespace, or ``kind`` is unknown.
|
||||
"""
|
||||
if kind not in TERMINAL_CLOSE_KINDS:
|
||||
raise TerminalTmuxSessionError(
|
||||
"unknown terminal close kind: {!r} (expected one of {})".format(
|
||||
kind, TERMINAL_CLOSE_KINDS
|
||||
)
|
||||
)
|
||||
if not isinstance(session_name, str) or not session_name.startswith(
|
||||
SESSION_NAME_PREFIX
|
||||
):
|
||||
raise TerminalTmuxSessionError(
|
||||
"refusing to close non-terminal tmux session: {!r}".format(session_name)
|
||||
)
|
||||
if kind == "detach":
|
||||
# The remote tmux session is left untouched; the caller is
|
||||
# responsible for closing the local Terminus tab.
|
||||
return TerminalCloseOutcome(kind=kind, executed=False, completed=None)
|
||||
completed = kill_terminal_session(
|
||||
host_alias,
|
||||
session_name,
|
||||
ssh_command_builder=ssh_command_builder,
|
||||
run=run,
|
||||
timeout=timeout,
|
||||
)
|
||||
return TerminalCloseOutcome(kind=kind, executed=True, completed=completed)
|
||||
|
||||
|
||||
def normalize_terminal_close_default(raw: object) -> Literal["detach", "plain"]:
|
||||
"""Coerce a Sublime-settings value into a known close-default mode.
|
||||
|
||||
Returns :data:`DEFAULT_TERMINAL_CLOSE_DEFAULT` (``"detach"``) for any
|
||||
value not in :data:`TERMINAL_CLOSE_DEFAULT_VALUES`, including
|
||||
``None``, non-string types, unknown strings, and case-mismatches.
|
||||
Whitespace is trimmed.
|
||||
"""
|
||||
if not isinstance(raw, str):
|
||||
return DEFAULT_TERMINAL_CLOSE_DEFAULT
|
||||
candidate = raw.strip().lower()
|
||||
if candidate in TERMINAL_CLOSE_DEFAULT_VALUES:
|
||||
return candidate # type: ignore[return-value]
|
||||
return DEFAULT_TERMINAL_CLOSE_DEFAULT
|
||||
|
||||
|
||||
def load_terminal_close_default_from_sublime() -> Literal["detach", "plain"]:
|
||||
"""Read ``sessions_terminal_close_default`` from ``Sessions.sublime-settings``.
|
||||
|
||||
Falls back to :data:`DEFAULT_TERMINAL_CLOSE_DEFAULT` whenever the
|
||||
Sublime API is unavailable (e.g. unit tests) or the stored value is
|
||||
unrecognised. Mirrors the loader pattern used by the rest of the
|
||||
plugin (``settings_model.load_sessions_settings_from_sublime`` and
|
||||
the per-feature helpers in ``commands.py``) so callers can swap this
|
||||
helper in without learning a new convention.
|
||||
"""
|
||||
try:
|
||||
sublime_mod = __import__("sublime")
|
||||
except ImportError:
|
||||
return DEFAULT_TERMINAL_CLOSE_DEFAULT
|
||||
load_settings = getattr(sublime_mod, "load_settings", None)
|
||||
if not callable(load_settings):
|
||||
return DEFAULT_TERMINAL_CLOSE_DEFAULT
|
||||
stored = load_settings("Sessions.sublime-settings")
|
||||
getter = getattr(stored, "get", None)
|
||||
if not callable(getter):
|
||||
return DEFAULT_TERMINAL_CLOSE_DEFAULT
|
||||
return normalize_terminal_close_default(
|
||||
getter("sessions_terminal_close_default", DEFAULT_TERMINAL_CLOSE_DEFAULT)
|
||||
)
|
||||
|
||||
|
||||
def build_remote_tmux_invocation(
|
||||
session_name: str,
|
||||
shell_preamble: str,
|
||||
shell_command: str,
|
||||
) -> str:
|
||||
"""Return the remote shell command that wraps the user's shell in tmux.
|
||||
|
||||
Structure:
|
||||
|
||||
cd <root> && (stty sane ...) && \\
|
||||
tmux new-session -A -s <name> <shell>
|
||||
|
||||
``new-session -A`` attaches to ``<name>`` if it already exists,
|
||||
otherwise spawns a new tmux session and runs ``<shell>`` inside it.
|
||||
The preamble runs before ``tmux`` so the terminal's initial ``cwd``
|
||||
matches whichever workspace root the caller passed — a fresh tmux
|
||||
session inherits it; a re-attached session keeps its own ``cwd``.
|
||||
|
||||
Args:
|
||||
session_name: Pre-validated tmux session name.
|
||||
shell_preamble: Shell command(s) run before ``tmux`` (for
|
||||
example ``cd /srv/app && (stty sane ...)``).
|
||||
shell_command: Final interactive shell to exec inside tmux.
|
||||
|
||||
Returns:
|
||||
A single shell command string ready to hand to
|
||||
``ssh -tt <host>`` as the remote invocation.
|
||||
"""
|
||||
# ``shell_command`` goes through tmux's own parser, so we pass it as
|
||||
# one positional arg after ``--``. ``shlex.quote`` would cause tmux
|
||||
# to see quoted surrounding characters; instead we trust the caller
|
||||
# to sanitise the shell command (the existing settings loader
|
||||
# rejects newlines, which covers the only "dangerous" case).
|
||||
return "{preamble} && tmux new-session -A -s {name} {shell}".format(
|
||||
preamble=shell_preamble,
|
||||
name=_shell_single_quote(session_name),
|
||||
shell=shell_command,
|
||||
)
|
||||
|
||||
|
||||
def probe_tmux_available(
|
||||
host_alias: str,
|
||||
*,
|
||||
ssh_command_builder: Optional[Callable[[str], Sequence[str]]] = None,
|
||||
run: Optional[RunFn] = None,
|
||||
timeout: float = 10.0,
|
||||
) -> TmuxProbeResult:
|
||||
"""Probe ``command -v tmux`` on ``host_alias`` and return the outcome.
|
||||
|
||||
A zero exit with non-empty stdout is treated as "tmux available".
|
||||
Any other outcome (missing binary, SSH failure, timeout) is folded
|
||||
into ``available=False`` so the caller can fall back to the
|
||||
direct-shell spawn without a try/except dance.
|
||||
|
||||
Args:
|
||||
host_alias: SSH config alias. Not validated here — the caller
|
||||
is expected to pass a known-good value (the connect flow
|
||||
already filters it).
|
||||
ssh_command_builder: Maps an alias to an argv prefix for remote
|
||||
commands. Defaults to ``["ssh", alias]``.
|
||||
run: Override for ``subprocess.run`` used for the probe. Tests
|
||||
typically pass a stub recorder.
|
||||
timeout: Seconds before the probe gives up. Defaults to 10.
|
||||
|
||||
Returns:
|
||||
A :class:`TmuxProbeResult` describing the probe outcome.
|
||||
"""
|
||||
builder = ssh_command_builder or _default_ssh_command_builder
|
||||
run_fn = run or subprocess.run
|
||||
argv: List[str] = list(builder(host_alias)) + ["command", "-v", "tmux"]
|
||||
try:
|
||||
completed = run_fn(
|
||||
argv,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
timeout=timeout,
|
||||
check=False,
|
||||
text=True,
|
||||
**_subprocess_no_window_kwargs(),
|
||||
)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
return TmuxProbeResult(
|
||||
available=False,
|
||||
exit_code=-1,
|
||||
stdout="",
|
||||
stderr="timeout after {}s: {}".format(timeout, exc),
|
||||
)
|
||||
except OSError as exc:
|
||||
return TmuxProbeResult(
|
||||
available=False,
|
||||
exit_code=-1,
|
||||
stdout="",
|
||||
stderr="ssh probe failed: {}".format(exc),
|
||||
)
|
||||
stdout = (completed.stdout or "").strip()
|
||||
stderr = (completed.stderr or "").strip()
|
||||
available = completed.returncode == 0 and bool(stdout)
|
||||
return TmuxProbeResult(
|
||||
available=available,
|
||||
exit_code=int(completed.returncode),
|
||||
stdout=stdout,
|
||||
stderr=stderr,
|
||||
)
|
||||
|
||||
|
||||
def _default_ssh_command_builder(alias: str) -> List[str]:
|
||||
"""Return the default ``ssh <alias>`` argv prefix for remote commands."""
|
||||
return ["ssh", alias]
|
||||
|
||||
|
||||
def _shell_single_quote(value: str) -> str:
|
||||
"""Return ``value`` single-quoted for POSIX shell embedding.
|
||||
|
||||
``session_name_for_host`` already guarantees the alphabet is safe,
|
||||
but we single-quote the result defensively so a future loosening of
|
||||
the validator can't turn into a shell-injection regression.
|
||||
"""
|
||||
return "'" + value.replace("'", "'\"'\"'") + "'"
|
||||
|
||||
|
||||
__all__ = (
|
||||
"DEFAULT_TERMINAL_CLOSE_DEFAULT",
|
||||
"SESSION_NAME_PREFIX",
|
||||
"TERMINAL_CLOSE_DEFAULT_VALUES",
|
||||
"TERMINAL_CLOSE_KINDS",
|
||||
"TerminalCloseKind",
|
||||
"TerminalCloseOutcome",
|
||||
"TerminalTmuxSessionError",
|
||||
"TmuxProbeResult",
|
||||
"build_remote_tmux_invocation",
|
||||
"close_terminal_session",
|
||||
"kill_terminal_session",
|
||||
"list_all_remote_tmux_sessions",
|
||||
"list_terminal_sessions",
|
||||
"load_terminal_close_default_from_sublime",
|
||||
"next_terminal_session_name",
|
||||
"normalize_terminal_close_default",
|
||||
"probe_tmux_available",
|
||||
"session_name_for_host",
|
||||
)
|
||||
@@ -7,7 +7,7 @@ import json
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import TYPE_CHECKING, Dict, Iterable, List, Literal, Optional, Tuple
|
||||
from typing import TYPE_CHECKING, Dict, Iterable, Literal, Optional, Tuple
|
||||
|
||||
from .settings_model import SessionsSettings
|
||||
|
||||
@@ -634,105 +634,3 @@ def reset_deferred_directories() -> None:
|
||||
"""Forget every deferred-directory entry (test / teardown helper)."""
|
||||
with _DEFERRED_DIRECTORIES_LOCK:
|
||||
_DEFERRED_DIRECTORIES_BY_CACHE_KEY.clear()
|
||||
|
||||
|
||||
# --- agent pair registry -----------------------------------------------------
|
||||
#
|
||||
# Each (workspace, agent_id) is one "pair". The broker keeps the tmux session
|
||||
# alive on the remote; the pair record here is the Python-side handle the
|
||||
# switcher view + kill command operate on. Storage is module-global because a
|
||||
# user may have multiple windows open against the same pair; storing per
|
||||
# window would double-count.
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AgentPair:
|
||||
"""One (workspace × agent) binding with rendering + switch metadata."""
|
||||
|
||||
workspace_cache_key: str
|
||||
host_alias: str
|
||||
agent_id: str
|
||||
agent_label: str
|
||||
workspace_label: str
|
||||
session_name: str
|
||||
created_at: float
|
||||
last_activated_at: float
|
||||
|
||||
@property
|
||||
def pair_id(self) -> str:
|
||||
"""Stable identifier used by the switcher view + palette commands."""
|
||||
return f"{self.workspace_cache_key}:{self.agent_id}"
|
||||
|
||||
|
||||
_AGENT_PAIRS_LOCK = threading.Lock()
|
||||
_AGENT_PAIRS_BY_ID: Dict[str, AgentPair] = {}
|
||||
_ACTIVE_PAIR_BY_WORKSPACE: Dict[str, str] = {}
|
||||
|
||||
|
||||
def register_agent_pair(pair: AgentPair) -> AgentPair:
|
||||
"""Insert or replace ``pair`` in the registry, return the stored value.
|
||||
|
||||
When an entry for the same ``pair_id`` already exists, ``created_at``
|
||||
is preserved and only ``last_activated_at`` advances. Marks the pair as
|
||||
the workspace's active pair.
|
||||
"""
|
||||
with _AGENT_PAIRS_LOCK:
|
||||
existing = _AGENT_PAIRS_BY_ID.get(pair.pair_id)
|
||||
stored = (
|
||||
AgentPair(
|
||||
workspace_cache_key=pair.workspace_cache_key,
|
||||
host_alias=pair.host_alias,
|
||||
agent_id=pair.agent_id,
|
||||
agent_label=pair.agent_label,
|
||||
workspace_label=pair.workspace_label,
|
||||
session_name=pair.session_name,
|
||||
created_at=existing.created_at,
|
||||
last_activated_at=pair.last_activated_at,
|
||||
)
|
||||
if existing is not None
|
||||
else pair
|
||||
)
|
||||
_AGENT_PAIRS_BY_ID[stored.pair_id] = stored
|
||||
_ACTIVE_PAIR_BY_WORKSPACE[stored.workspace_cache_key] = stored.pair_id
|
||||
return stored
|
||||
|
||||
|
||||
def forget_agent_pair(pair_id: str) -> Optional[AgentPair]:
|
||||
"""Remove ``pair_id``; clear active-pair pointers that referenced it."""
|
||||
with _AGENT_PAIRS_LOCK:
|
||||
removed = _AGENT_PAIRS_BY_ID.pop(pair_id, None)
|
||||
if removed is None:
|
||||
return None
|
||||
ws_key = removed.workspace_cache_key
|
||||
if _ACTIVE_PAIR_BY_WORKSPACE.get(ws_key) == pair_id:
|
||||
_ACTIVE_PAIR_BY_WORKSPACE.pop(ws_key, None)
|
||||
return removed
|
||||
|
||||
|
||||
def list_agent_pairs() -> List[AgentPair]:
|
||||
"""Return all known pairs ordered by most-recently-activated first."""
|
||||
with _AGENT_PAIRS_LOCK:
|
||||
return sorted(
|
||||
_AGENT_PAIRS_BY_ID.values(),
|
||||
key=lambda p: p.last_activated_at,
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
|
||||
def active_agent_pair_id(workspace_cache_key: str) -> Optional[str]:
|
||||
"""Return the pair_id flagged as active for ``workspace_cache_key``, or None."""
|
||||
with _AGENT_PAIRS_LOCK:
|
||||
return _ACTIVE_PAIR_BY_WORKSPACE.get(workspace_cache_key)
|
||||
|
||||
|
||||
def lookup_agent_pair(pair_id: str) -> Optional[AgentPair]:
|
||||
"""Return the stored pair for ``pair_id`` (exact match)."""
|
||||
with _AGENT_PAIRS_LOCK:
|
||||
return _AGENT_PAIRS_BY_ID.get(pair_id)
|
||||
|
||||
|
||||
def reset_agent_pairs() -> None:
|
||||
"""Forget every agent pair (test / teardown helper)."""
|
||||
with _AGENT_PAIRS_LOCK:
|
||||
_AGENT_PAIRS_BY_ID.clear()
|
||||
_ACTIVE_PAIR_BY_WORKSPACE.clear()
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
"""Unit tests for the agent-pair registry helpers in ``workspace_state``."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sessions.workspace_state import (
|
||||
AgentPair,
|
||||
active_agent_pair_id,
|
||||
forget_agent_pair,
|
||||
list_agent_pairs,
|
||||
lookup_agent_pair,
|
||||
register_agent_pair,
|
||||
reset_agent_pairs,
|
||||
)
|
||||
|
||||
|
||||
def _pair(
|
||||
cache_key: str = "ws1",
|
||||
agent_id: str = "claude",
|
||||
created_at: float = 100.0,
|
||||
last_activated_at: float = 200.0,
|
||||
) -> AgentPair:
|
||||
return AgentPair(
|
||||
workspace_cache_key=cache_key,
|
||||
host_alias="dev",
|
||||
agent_id=agent_id,
|
||||
agent_label=agent_id,
|
||||
workspace_label="proj",
|
||||
session_name="sessions-agent-{}-{}".format(cache_key[:8], agent_id),
|
||||
created_at=created_at,
|
||||
last_activated_at=last_activated_at,
|
||||
)
|
||||
|
||||
|
||||
def setup_function() -> None:
|
||||
reset_agent_pairs()
|
||||
|
||||
|
||||
def test_register_agent_pair_stores_and_marks_active() -> None:
|
||||
pair = _pair()
|
||||
stored = register_agent_pair(pair)
|
||||
assert stored == pair
|
||||
assert lookup_agent_pair(pair.pair_id) == pair
|
||||
assert active_agent_pair_id("ws1") == pair.pair_id
|
||||
|
||||
|
||||
def test_register_preserves_created_at_on_reactivation() -> None:
|
||||
first = _pair(created_at=100.0, last_activated_at=100.0)
|
||||
register_agent_pair(first)
|
||||
reactivated = register_agent_pair(_pair(created_at=500.0, last_activated_at=300.0))
|
||||
assert reactivated.created_at == 100.0
|
||||
assert reactivated.last_activated_at == 300.0
|
||||
|
||||
|
||||
def test_list_agent_pairs_orders_by_last_activated_desc() -> None:
|
||||
register_agent_pair(_pair(cache_key="ws1", last_activated_at=100.0))
|
||||
register_agent_pair(
|
||||
_pair(cache_key="ws2", agent_id="codex", last_activated_at=500.0)
|
||||
)
|
||||
register_agent_pair(
|
||||
_pair(cache_key="ws1", agent_id="codex", last_activated_at=250.0)
|
||||
)
|
||||
ordered = list_agent_pairs()
|
||||
assert [p.last_activated_at for p in ordered] == [500.0, 250.0, 100.0]
|
||||
|
||||
|
||||
def test_forget_agent_pair_clears_active_pointer() -> None:
|
||||
pair = _pair()
|
||||
register_agent_pair(pair)
|
||||
assert active_agent_pair_id("ws1") == pair.pair_id
|
||||
removed = forget_agent_pair(pair.pair_id)
|
||||
assert removed == pair
|
||||
assert active_agent_pair_id("ws1") is None
|
||||
assert lookup_agent_pair(pair.pair_id) is None
|
||||
|
||||
|
||||
def test_forget_unknown_pair_returns_none() -> None:
|
||||
assert forget_agent_pair("never:existed") is None
|
||||
|
||||
|
||||
def test_register_different_agents_same_workspace_sets_latest_active() -> None:
|
||||
register_agent_pair(_pair(agent_id="claude", last_activated_at=100.0))
|
||||
register_agent_pair(_pair(agent_id="codex", last_activated_at=300.0))
|
||||
assert active_agent_pair_id("ws1") == "ws1:codex"
|
||||
|
||||
|
||||
def test_pair_id_is_workspace_colon_agent() -> None:
|
||||
pair = _pair(cache_key="abc123", agent_id="claude")
|
||||
assert pair.pair_id == "abc123:claude"
|
||||
|
||||
|
||||
def test_reset_agent_pairs_clears_everything() -> None:
|
||||
register_agent_pair(_pair())
|
||||
register_agent_pair(_pair(cache_key="ws2", agent_id="codex"))
|
||||
reset_agent_pairs()
|
||||
assert list_agent_pairs() == []
|
||||
assert active_agent_pair_id("ws1") is None
|
||||
assert active_agent_pair_id("ws2") is None
|
||||
@@ -1,323 +0,0 @@
|
||||
"""Tests for ``agent_remote_payload`` (Rust via ``local_bridge`` only)."""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from sessions.agent_remote_payload import (
|
||||
AGENT_EDITOR_PREVIEW_KIND,
|
||||
SUPPORTED_SCHEMA_VERSION,
|
||||
AgentEditorPayload,
|
||||
parse_agent_editor_envelope_from_stdout,
|
||||
parse_agent_editor_payload,
|
||||
)
|
||||
from sessions.ssh_file_transport import _try_resolved_local_bridge_binary_path
|
||||
|
||||
|
||||
@pytest.fixture(scope="module", autouse=True)
|
||||
def _require_local_bridge() -> None:
|
||||
if _try_resolved_local_bridge_binary_path() is None:
|
||||
pytest.fail(
|
||||
"local_bridge is required for these tests. From repo root: "
|
||||
"cargo build -p local_bridge"
|
||||
)
|
||||
|
||||
|
||||
def test_parse_agent_editor_payload_round_trip() -> None:
|
||||
raw = {
|
||||
"kind": AGENT_EDITOR_PREVIEW_KIND,
|
||||
"schema_version": SUPPORTED_SCHEMA_VERSION,
|
||||
"title": "Preview",
|
||||
"unified_diff": "--- a/x\n+++ b/x\n",
|
||||
"target_remote_path": "/srv/app/readme.md",
|
||||
}
|
||||
parsed = parse_agent_editor_payload(raw)
|
||||
assert parsed == AgentEditorPayload(
|
||||
kind=AGENT_EDITOR_PREVIEW_KIND,
|
||||
schema_version=SUPPORTED_SCHEMA_VERSION,
|
||||
title="Preview",
|
||||
unified_diff="--- a/x\n+++ b/x\n",
|
||||
target_remote_path="/srv/app/readme.md",
|
||||
)
|
||||
|
||||
|
||||
def test_parse_agent_editor_payload_optional_path_omitted() -> None:
|
||||
raw = {
|
||||
"kind": AGENT_EDITOR_PREVIEW_KIND,
|
||||
"schema_version": SUPPORTED_SCHEMA_VERSION,
|
||||
"title": "t",
|
||||
"unified_diff": "d",
|
||||
}
|
||||
parsed = parse_agent_editor_payload(raw)
|
||||
assert parsed is not None
|
||||
assert parsed.target_remote_path is None
|
||||
|
||||
|
||||
def test_parse_agent_editor_payload_rejects_wrong_kind() -> None:
|
||||
assert (
|
||||
parse_agent_editor_payload(
|
||||
{
|
||||
"kind": "other",
|
||||
"schema_version": SUPPORTED_SCHEMA_VERSION,
|
||||
"title": "t",
|
||||
"unified_diff": "d",
|
||||
}
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
def test_parse_agent_editor_payload_rejects_bad_schema() -> None:
|
||||
assert (
|
||||
parse_agent_editor_payload(
|
||||
{
|
||||
"kind": AGENT_EDITOR_PREVIEW_KIND,
|
||||
"schema_version": 99,
|
||||
"title": "t",
|
||||
"unified_diff": "d",
|
||||
}
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
def test_parse_agent_editor_payload_rejects_non_dict() -> None:
|
||||
assert parse_agent_editor_payload([]) is None
|
||||
assert parse_agent_editor_payload("x") is None
|
||||
|
||||
|
||||
def test_parse_agent_editor_payload_rejects_non_int_schema() -> None:
|
||||
assert (
|
||||
parse_agent_editor_payload(
|
||||
{
|
||||
"kind": AGENT_EDITOR_PREVIEW_KIND,
|
||||
"schema_version": True,
|
||||
"title": "t",
|
||||
"unified_diff": "d",
|
||||
}
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
def test_parse_agent_editor_payload_rejects_whitespace_only_title_or_diff() -> None:
|
||||
assert (
|
||||
parse_agent_editor_payload(
|
||||
{
|
||||
"kind": AGENT_EDITOR_PREVIEW_KIND,
|
||||
"schema_version": SUPPORTED_SCHEMA_VERSION,
|
||||
"title": " ",
|
||||
"unified_diff": "x",
|
||||
}
|
||||
)
|
||||
is None
|
||||
)
|
||||
assert (
|
||||
parse_agent_editor_payload(
|
||||
{
|
||||
"kind": AGENT_EDITOR_PREVIEW_KIND,
|
||||
"schema_version": SUPPORTED_SCHEMA_VERSION,
|
||||
"title": "ok",
|
||||
"unified_diff": "\n\t\n",
|
||||
}
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
def test_parse_agent_editor_envelope_from_stdout_json_error_detail() -> None:
|
||||
payload, err = parse_agent_editor_envelope_from_stdout("not json")
|
||||
assert payload is None
|
||||
assert err is not None
|
||||
assert "JSON decode failed" in err
|
||||
|
||||
|
||||
def test_parse_agent_editor_envelope_from_stdout_schema_message() -> None:
|
||||
raw = {
|
||||
"kind": AGENT_EDITOR_PREVIEW_KIND,
|
||||
"schema_version": 99,
|
||||
"title": "t",
|
||||
"unified_diff": "d",
|
||||
}
|
||||
|
||||
payload, err = parse_agent_editor_envelope_from_stdout(json.dumps(raw))
|
||||
assert payload is None
|
||||
assert err is not None
|
||||
assert "Schema validation failed" in err
|
||||
|
||||
|
||||
def test_parse_agent_editor_envelope_last_line_with_prefix_logs() -> None:
|
||||
body = {
|
||||
"kind": AGENT_EDITOR_PREVIEW_KIND,
|
||||
"schema_version": SUPPORTED_SCHEMA_VERSION,
|
||||
"title": "last",
|
||||
"unified_diff": "d",
|
||||
}
|
||||
text = "noise line\n{}".format(json.dumps(body))
|
||||
payload, err = parse_agent_editor_envelope_from_stdout(text)
|
||||
assert err is None
|
||||
assert payload is not None
|
||||
assert payload.title == "last"
|
||||
|
||||
|
||||
# --- edge-case: _parse_via_local_bridge error paths ---
|
||||
|
||||
|
||||
def test_parse_via_bridge_returns_error_when_bridge_missing(monkeypatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
"sessions.ssh_file_transport._try_resolved_local_bridge_binary_path",
|
||||
lambda: None,
|
||||
)
|
||||
from sessions.agent_remote_payload import _parse_via_local_bridge
|
||||
|
||||
payload, err = _parse_via_local_bridge("text")
|
||||
assert payload is None
|
||||
assert err is not None
|
||||
assert "local_bridge binary not found" in err
|
||||
|
||||
|
||||
def test_parse_via_bridge_handles_oserror(monkeypatch) -> None:
|
||||
import subprocess
|
||||
|
||||
monkeypatch.setattr(
|
||||
"sessions.ssh_file_transport._try_resolved_local_bridge_binary_path",
|
||||
lambda: "/usr/bin/nonexistent-sessions-bridge-999",
|
||||
)
|
||||
|
||||
def raise_os(*args, **kwargs):
|
||||
raise OSError("spawn failed")
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", raise_os)
|
||||
from sessions.agent_remote_payload import _parse_via_local_bridge
|
||||
|
||||
payload, err = _parse_via_local_bridge("text")
|
||||
assert payload is None
|
||||
assert "spawn failed" in err
|
||||
|
||||
|
||||
def test_parse_via_bridge_handles_timeout(monkeypatch) -> None:
|
||||
import subprocess
|
||||
|
||||
monkeypatch.setattr(
|
||||
"sessions.ssh_file_transport._try_resolved_local_bridge_binary_path",
|
||||
lambda: "/usr/bin/true",
|
||||
)
|
||||
|
||||
def raise_timeout(*args, **kwargs):
|
||||
raise subprocess.TimeoutExpired(cmd=["bridge"], timeout=15)
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", raise_timeout)
|
||||
from sessions.agent_remote_payload import _parse_via_local_bridge
|
||||
|
||||
payload, err = _parse_via_local_bridge("text")
|
||||
assert payload is None
|
||||
assert "parse failed" in err
|
||||
|
||||
|
||||
def test_parse_via_bridge_nonzero_exit(monkeypatch) -> None:
|
||||
import subprocess
|
||||
|
||||
monkeypatch.setattr(
|
||||
"sessions.ssh_file_transport._try_resolved_local_bridge_binary_path",
|
||||
lambda: "/usr/bin/true",
|
||||
)
|
||||
|
||||
def fake_run(*args, **kwargs):
|
||||
return subprocess.CompletedProcess([], 1, stdout="", stderr="bad input")
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", fake_run)
|
||||
from sessions.agent_remote_payload import _parse_via_local_bridge
|
||||
|
||||
payload, err = _parse_via_local_bridge("text")
|
||||
assert payload is None
|
||||
assert "bad input" in err
|
||||
|
||||
|
||||
def test_parse_via_bridge_invalid_json_output(monkeypatch) -> None:
|
||||
import subprocess
|
||||
|
||||
monkeypatch.setattr(
|
||||
"sessions.ssh_file_transport._try_resolved_local_bridge_binary_path",
|
||||
lambda: "/usr/bin/true",
|
||||
)
|
||||
|
||||
def fake_run(*args, **kwargs):
|
||||
return subprocess.CompletedProcess([], 0, stdout="not json", stderr="")
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", fake_run)
|
||||
from sessions.agent_remote_payload import _parse_via_local_bridge
|
||||
|
||||
payload, err = _parse_via_local_bridge("text")
|
||||
assert payload is None
|
||||
assert "invalid JSON" in err
|
||||
|
||||
|
||||
def test_parse_via_bridge_missing_result_object(monkeypatch) -> None:
|
||||
import subprocess
|
||||
|
||||
monkeypatch.setattr(
|
||||
"sessions.ssh_file_transport._try_resolved_local_bridge_binary_path",
|
||||
lambda: "/usr/bin/true",
|
||||
)
|
||||
|
||||
def fake_run(*args, **kwargs):
|
||||
return subprocess.CompletedProcess([], 0, stdout='{"result": "bad"}', stderr="")
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", fake_run)
|
||||
from sessions.agent_remote_payload import _parse_via_local_bridge
|
||||
|
||||
payload, err = _parse_via_local_bridge("text")
|
||||
assert payload is None
|
||||
assert "missing result object" in err
|
||||
|
||||
|
||||
def test_parse_via_bridge_with_error_string(monkeypatch) -> None:
|
||||
import subprocess
|
||||
|
||||
monkeypatch.setattr(
|
||||
"sessions.ssh_file_transport._try_resolved_local_bridge_binary_path",
|
||||
lambda: "/usr/bin/true",
|
||||
)
|
||||
|
||||
def fake_run(*args, **kwargs):
|
||||
return subprocess.CompletedProcess(
|
||||
[],
|
||||
0,
|
||||
stdout=json.dumps({"result": {"agent_editor_error": "schema mismatch"}}),
|
||||
stderr="",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", fake_run)
|
||||
from sessions.agent_remote_payload import _parse_via_local_bridge
|
||||
|
||||
payload, err = _parse_via_local_bridge("text")
|
||||
assert payload is None
|
||||
assert err == "schema mismatch"
|
||||
|
||||
|
||||
def test_parse_via_bridge_no_payload_no_error(monkeypatch) -> None:
|
||||
import subprocess
|
||||
|
||||
monkeypatch.setattr(
|
||||
"sessions.ssh_file_transport._try_resolved_local_bridge_binary_path",
|
||||
lambda: "/usr/bin/true",
|
||||
)
|
||||
|
||||
def fake_run(*args, **kwargs):
|
||||
return subprocess.CompletedProcess(
|
||||
[], 0, stdout=json.dumps({"result": {}}), stderr=""
|
||||
)
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", fake_run)
|
||||
from sessions.agent_remote_payload import _parse_via_local_bridge
|
||||
|
||||
payload, err = _parse_via_local_bridge("text")
|
||||
assert payload is None
|
||||
assert "no payload and no error" in err
|
||||
|
||||
|
||||
def test_parse_agent_editor_payload_non_serializable_dict() -> None:
|
||||
class Unserializable:
|
||||
pass
|
||||
|
||||
result = parse_agent_editor_payload({"key": Unserializable()})
|
||||
assert result is None
|
||||
@@ -1,382 +0,0 @@
|
||||
"""Unit tests for :mod:`sessions.agent_switcher_view`."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Mapping, Optional, Tuple
|
||||
|
||||
import pytest
|
||||
from sessions.agent_switcher_view import (
|
||||
NEW_PAIR_SENTINEL,
|
||||
SWITCHER_VIEW_SETTING_KEY,
|
||||
AgentPairSummary,
|
||||
SessionsAgentSwitcherClickListener,
|
||||
SessionsRenderAgentSwitcherCommand,
|
||||
dispatch_switcher_click,
|
||||
find_pair_at_line,
|
||||
render_switcher_body,
|
||||
)
|
||||
|
||||
|
||||
def _pair(
|
||||
pair_id: str,
|
||||
agent: str = "claude",
|
||||
*,
|
||||
attached: bool = False,
|
||||
active: bool = False,
|
||||
workspace: str = "repo",
|
||||
) -> AgentPairSummary:
|
||||
return AgentPairSummary(
|
||||
pair_id=pair_id,
|
||||
workspace_label=workspace,
|
||||
agent_label=agent,
|
||||
is_attached=attached,
|
||||
is_active=active,
|
||||
)
|
||||
|
||||
|
||||
def test_render_switcher_body_empty_list_has_menu_only() -> None:
|
||||
body = render_switcher_body([])
|
||||
lines = body.split("\n")
|
||||
assert len(lines) == 2
|
||||
assert "─" in lines[0]
|
||||
assert lines[1].strip() == "+ New agent session…"
|
||||
|
||||
|
||||
def test_render_switcher_body_includes_all_pairs_and_menu() -> None:
|
||||
pairs = [
|
||||
_pair("07c4844b:claude", agent="claude", active=True),
|
||||
_pair("07c4844b:codex", agent="codex", attached=True),
|
||||
_pair("a75c7f0f:claude", agent="claude"),
|
||||
]
|
||||
body = render_switcher_body(pairs)
|
||||
lines = body.split("\n")
|
||||
assert len(lines) == len(pairs) + 2 # sep + "+ New"
|
||||
# Active glyph is ● and lives on the active pair's row.
|
||||
assert "●" in lines[0]
|
||||
assert "○" in lines[1]
|
||||
assert "(active)" in lines[0]
|
||||
assert "[attached]" in lines[1]
|
||||
assert "(active)" not in lines[2]
|
||||
assert "[attached]" not in lines[2]
|
||||
assert lines[-1].strip().startswith("+ New agent session")
|
||||
|
||||
|
||||
def test_render_switcher_body_truncates_long_cache_key() -> None:
|
||||
body = render_switcher_body([_pair("0123456789abcdef0123:claude", agent="claude")])
|
||||
first = body.split("\n")[0]
|
||||
# Cache key column ends up with the 8-char prefix, not the full hash.
|
||||
assert "01234567" in first
|
||||
assert "89abcdef" not in first
|
||||
|
||||
|
||||
def test_render_switcher_body_both_active_and_attached() -> None:
|
||||
body = render_switcher_body([_pair("abc:claude", active=True, attached=True)])
|
||||
first_line = body.split("\n")[0]
|
||||
assert "(active)" in first_line
|
||||
assert "[attached]" in first_line
|
||||
|
||||
|
||||
def test_render_switcher_body_contains_no_emojis() -> None:
|
||||
body = render_switcher_body([_pair("abc:claude", active=True, attached=True)])
|
||||
# ASCII-only policy from user memory: reject the common culprits.
|
||||
for ch in body:
|
||||
assert ord(ch) < 0x1F000, "unexpected emoji {!r} in body".format(ch)
|
||||
|
||||
|
||||
def test_find_pair_at_line_resolves_rows_to_pair_ids() -> None:
|
||||
pairs = [
|
||||
_pair("07c4844b:claude"),
|
||||
_pair("a75c7f0f:codex"),
|
||||
]
|
||||
assert find_pair_at_line(0, pairs) == "07c4844b:claude"
|
||||
assert find_pair_at_line(1, pairs) == "a75c7f0f:codex"
|
||||
|
||||
|
||||
def test_find_pair_at_line_returns_none_for_separator() -> None:
|
||||
pairs = [_pair("07c4844b:claude")]
|
||||
# Pair on row 0, separator on row 1, "+ New" on row 2.
|
||||
assert find_pair_at_line(1, pairs) is None
|
||||
|
||||
|
||||
def test_find_pair_at_line_resolves_new_sentinel() -> None:
|
||||
pairs = [_pair("07c4844b:claude")]
|
||||
assert find_pair_at_line(2, pairs) == NEW_PAIR_SENTINEL
|
||||
|
||||
|
||||
@pytest.mark.parametrize("idx", [-1, -10, 99])
|
||||
def test_find_pair_at_line_out_of_range_is_none(idx: int) -> None:
|
||||
pairs = [_pair("07c4844b:claude")]
|
||||
assert find_pair_at_line(idx, pairs) is None
|
||||
|
||||
|
||||
def test_find_pair_at_line_empty_list_only_has_new_on_row_1() -> None:
|
||||
assert find_pair_at_line(0, []) is None # separator
|
||||
assert find_pair_at_line(1, []) == NEW_PAIR_SENTINEL
|
||||
assert find_pair_at_line(2, []) is None
|
||||
|
||||
|
||||
class _FakeSettings:
|
||||
def __init__(self, data: Optional[Dict[str, Any]] = None) -> None:
|
||||
self._data: Dict[str, Any] = dict(data or {})
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
return self._data.get(key, default)
|
||||
|
||||
def set(self, key: str, value: Any) -> None:
|
||||
self._data[key] = value
|
||||
|
||||
|
||||
class _FakeView:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
settings: Optional[Dict[str, Any]] = None,
|
||||
window: Optional[object] = None,
|
||||
row_for_point: Optional[Dict[int, int]] = None,
|
||||
) -> None:
|
||||
self._settings = _FakeSettings(settings)
|
||||
self._window = window
|
||||
self._row_for_point: Dict[int, int] = dict(row_for_point or {})
|
||||
|
||||
def settings(self) -> _FakeSettings:
|
||||
return self._settings
|
||||
|
||||
def window(self) -> Optional[object]:
|
||||
return self._window
|
||||
|
||||
def window_to_text(self, xy: Tuple[int, int]) -> int:
|
||||
# Tests feed a known point back via ``event.x == y``; we use a
|
||||
# tiny identity map so dispatch tests can control the row.
|
||||
return xy[0]
|
||||
|
||||
def rowcol(self, point: int) -> Tuple[int, int]:
|
||||
row = self._row_for_point.get(point, point)
|
||||
return (row, 0)
|
||||
|
||||
|
||||
class _FakeWindow:
|
||||
def __init__(self) -> None:
|
||||
self.run_command_calls: List[Tuple[str, Mapping[str, Any]]] = []
|
||||
|
||||
def run_command(self, name: str, args: Optional[Mapping[str, Any]] = None) -> None:
|
||||
self.run_command_calls.append((name, dict(args or {})))
|
||||
|
||||
|
||||
def test_dispatch_switcher_click_returns_switch_command_for_pair_row() -> None:
|
||||
pairs = [_pair("07c4844b:claude"), _pair("a75c7f0f:codex")]
|
||||
view = _FakeView(row_for_point={10: 1})
|
||||
result = dispatch_switcher_click(view, {"x": 10, "y": 10}, pairs)
|
||||
assert result is not None
|
||||
assert result["command"] == "sessions_switch_agent_session"
|
||||
assert result["args"] == {"pair_id": "a75c7f0f:codex"}
|
||||
|
||||
|
||||
def test_dispatch_switcher_click_returns_new_session_for_sentinel_row() -> None:
|
||||
pairs = [_pair("07c4844b:claude")]
|
||||
# Sentinel row (index 2 → point 2 → rowcol fallback).
|
||||
view = _FakeView()
|
||||
result = dispatch_switcher_click(view, {"x": 2, "y": 2}, pairs)
|
||||
assert result is not None
|
||||
assert result["command"] == "sessions_new_agent_session"
|
||||
assert result["args"] == {}
|
||||
|
||||
|
||||
def test_dispatch_switcher_click_none_for_separator_row() -> None:
|
||||
pairs = [_pair("07c4844b:claude")]
|
||||
view = _FakeView(row_for_point={5: 1}) # row 1 == separator
|
||||
result = dispatch_switcher_click(view, {"x": 5, "y": 5}, pairs)
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_dispatch_switcher_click_none_without_coordinates() -> None:
|
||||
pairs = [_pair("07c4844b:claude")]
|
||||
view = _FakeView()
|
||||
assert dispatch_switcher_click(view, {"x": None, "y": None}, pairs) is None
|
||||
assert dispatch_switcher_click(view, {}, pairs) is None
|
||||
|
||||
|
||||
def test_listener_ignores_non_switcher_views() -> None:
|
||||
listener = SessionsAgentSwitcherClickListener()
|
||||
view = _FakeView(settings={SWITCHER_VIEW_SETTING_KEY: False})
|
||||
# Should be a silent no-op even though drag_select matches.
|
||||
listener.on_text_command(view, "drag_select", {"event": {"x": 1, "y": 1}})
|
||||
|
||||
|
||||
def test_listener_ignores_non_drag_select_commands() -> None:
|
||||
listener = SessionsAgentSwitcherClickListener()
|
||||
view = _FakeView(settings={SWITCHER_VIEW_SETTING_KEY: True})
|
||||
listener.on_text_command(view, "move", {"event": {"x": 1, "y": 1}})
|
||||
|
||||
|
||||
def test_listener_fires_switch_command_on_pair_row_click() -> None:
|
||||
listener = SessionsAgentSwitcherClickListener()
|
||||
window = _FakeWindow()
|
||||
pairs_raw = [
|
||||
{
|
||||
"pair_id": "07c4844b:claude",
|
||||
"workspace_label": "repo",
|
||||
"agent_label": "claude",
|
||||
"is_attached": False,
|
||||
"is_active": True,
|
||||
},
|
||||
{
|
||||
"pair_id": "a75c7f0f:codex",
|
||||
"workspace_label": "repo",
|
||||
"agent_label": "codex",
|
||||
"is_attached": True,
|
||||
"is_active": False,
|
||||
},
|
||||
]
|
||||
view = _FakeView(
|
||||
settings={
|
||||
SWITCHER_VIEW_SETTING_KEY: True,
|
||||
"sessions_agent_pairs": pairs_raw,
|
||||
},
|
||||
window=window,
|
||||
row_for_point={42: 1},
|
||||
)
|
||||
listener.on_text_command(view, "drag_select", {"event": {"x": 42, "y": 42}})
|
||||
assert window.run_command_calls == [
|
||||
(
|
||||
"sessions_switch_agent_session",
|
||||
{"pair_id": "a75c7f0f:codex"},
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def test_listener_fires_new_session_on_plus_row() -> None:
|
||||
listener = SessionsAgentSwitcherClickListener()
|
||||
window = _FakeWindow()
|
||||
pairs_raw = [
|
||||
{
|
||||
"pair_id": "07c4844b:claude",
|
||||
"workspace_label": "repo",
|
||||
"agent_label": "claude",
|
||||
"is_attached": False,
|
||||
"is_active": True,
|
||||
},
|
||||
]
|
||||
view = _FakeView(
|
||||
settings={
|
||||
SWITCHER_VIEW_SETTING_KEY: True,
|
||||
"sessions_agent_pairs": pairs_raw,
|
||||
},
|
||||
window=window,
|
||||
row_for_point={7: 2}, # row 2 == "+ New"
|
||||
)
|
||||
listener.on_text_command(view, "drag_select", {"event": {"x": 7, "y": 7}})
|
||||
assert window.run_command_calls == [("sessions_new_agent_session", {})]
|
||||
|
||||
|
||||
def test_listener_swallows_click_when_pairs_cache_missing() -> None:
|
||||
listener = SessionsAgentSwitcherClickListener()
|
||||
window = _FakeWindow()
|
||||
view = _FakeView(
|
||||
settings={SWITCHER_VIEW_SETTING_KEY: True},
|
||||
window=window,
|
||||
row_for_point={0: 0},
|
||||
)
|
||||
listener.on_text_command(view, "drag_select", {"event": {"x": 0, "y": 0}})
|
||||
assert window.run_command_calls == []
|
||||
|
||||
|
||||
def test_listener_swallows_click_on_separator() -> None:
|
||||
listener = SessionsAgentSwitcherClickListener()
|
||||
window = _FakeWindow()
|
||||
pairs_raw = [
|
||||
{
|
||||
"pair_id": "07c4844b:claude",
|
||||
"workspace_label": "repo",
|
||||
"agent_label": "claude",
|
||||
"is_attached": False,
|
||||
"is_active": True,
|
||||
},
|
||||
]
|
||||
view = _FakeView(
|
||||
settings={
|
||||
SWITCHER_VIEW_SETTING_KEY: True,
|
||||
"sessions_agent_pairs": pairs_raw,
|
||||
},
|
||||
window=window,
|
||||
row_for_point={9: 1}, # separator row
|
||||
)
|
||||
listener.on_text_command(view, "drag_select", {"event": {"x": 9, "y": 9}})
|
||||
assert window.run_command_calls == []
|
||||
|
||||
|
||||
class _RenderableView:
|
||||
def __init__(self, initial: str = "") -> None:
|
||||
self._text = initial
|
||||
self.read_only_history: List[bool] = []
|
||||
self.inserts: List[Tuple[int, str]] = []
|
||||
self.erase_calls: List[Any] = []
|
||||
|
||||
def set_read_only(self, flag: bool) -> None:
|
||||
self.read_only_history.append(flag)
|
||||
|
||||
def size(self) -> int:
|
||||
return len(self._text)
|
||||
|
||||
def erase(self, edit: object, region: Any) -> None:
|
||||
self.erase_calls.append(region)
|
||||
self._text = ""
|
||||
|
||||
def insert(self, edit: object, point: int, value: str) -> None:
|
||||
self.inserts.append((point, value))
|
||||
self._text = value
|
||||
|
||||
|
||||
def test_render_command_replaces_body_and_toggles_read_only() -> None:
|
||||
view = _RenderableView(initial="stale content")
|
||||
command = SessionsRenderAgentSwitcherCommand.__new__(
|
||||
SessionsRenderAgentSwitcherCommand
|
||||
)
|
||||
command.view = view # type: ignore[attr-defined]
|
||||
command.run(edit=object(), body="fresh\nbody")
|
||||
# Read-only toggle: False during edit, True at the end.
|
||||
assert view.read_only_history == [False, True]
|
||||
assert view.erase_calls, "erase should have been invoked"
|
||||
assert view.inserts == [(0, "fresh\nbody")]
|
||||
|
||||
|
||||
def test_render_command_noop_without_view() -> None:
|
||||
command = SessionsRenderAgentSwitcherCommand.__new__(
|
||||
SessionsRenderAgentSwitcherCommand
|
||||
)
|
||||
# No view attached at all; run must not raise.
|
||||
command.run(edit=object(), body="whatever")
|
||||
|
||||
|
||||
def test_render_command_skips_edit_when_view_missing_methods() -> None:
|
||||
class _HalfView:
|
||||
def __init__(self) -> None:
|
||||
self.read_only_history: List[bool] = []
|
||||
|
||||
def set_read_only(self, flag: bool) -> None:
|
||||
self.read_only_history.append(flag)
|
||||
|
||||
view = _HalfView()
|
||||
command = SessionsRenderAgentSwitcherCommand.__new__(
|
||||
SessionsRenderAgentSwitcherCommand
|
||||
)
|
||||
command.view = view # type: ignore[attr-defined]
|
||||
command.run(edit=object(), body="x")
|
||||
# set_read_only should still run both False and True so the buffer
|
||||
# doesn't end up stuck in a writable state if methods are missing.
|
||||
assert view.read_only_history == [False, True]
|
||||
|
||||
|
||||
def test_cached_pairs_returns_none_for_bad_schema_entries() -> None:
|
||||
# Trigger the listener path when pair entries miss ``pair_id``.
|
||||
listener = SessionsAgentSwitcherClickListener()
|
||||
window = _FakeWindow()
|
||||
view = _FakeView(
|
||||
settings={
|
||||
SWITCHER_VIEW_SETTING_KEY: True,
|
||||
"sessions_agent_pairs": [{"workspace_label": "x"}], # no pair_id
|
||||
},
|
||||
window=window,
|
||||
row_for_point={0: 0},
|
||||
)
|
||||
listener.on_text_command(view, "drag_select", {"event": {"x": 0, "y": 0}})
|
||||
assert window.run_command_calls == []
|
||||
@@ -1,315 +0,0 @@
|
||||
"""Unit tests for the ``agent_tmux`` tmux session broker."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import FrozenInstanceError
|
||||
from types import SimpleNamespace
|
||||
from typing import List, Tuple
|
||||
|
||||
import pytest
|
||||
from sessions.agent_tmux import (
|
||||
AgentTmuxBroker,
|
||||
AgentTmuxError,
|
||||
TmuxAgentSession,
|
||||
_build_session_name,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _RunRecorder:
|
||||
"""Record subprocess.run calls and replay scripted responses in order."""
|
||||
|
||||
def __init__(self, responses: List[Tuple[int, str, str]]) -> None:
|
||||
self._responses = list(responses)
|
||||
self.calls: List[List[str]] = []
|
||||
|
||||
def __call__(self, argv, **kwargs): # type: ignore[no-untyped-def]
|
||||
self.calls.append(list(argv))
|
||||
if not self._responses:
|
||||
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
||||
rc, out, err = self._responses.pop(0)
|
||||
return SimpleNamespace(returncode=rc, stdout=out, stderr=err)
|
||||
|
||||
|
||||
def _ssh_builder(alias: str) -> List[str]:
|
||||
return ["ssh", "-F", "/fake/config", alias]
|
||||
|
||||
|
||||
def _broker(
|
||||
responses: List[Tuple[int, str, str]],
|
||||
) -> Tuple[AgentTmuxBroker, _RunRecorder]:
|
||||
run = _RunRecorder(responses)
|
||||
broker = AgentTmuxBroker(ssh_command_builder=_ssh_builder, run=run)
|
||||
return broker, run
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Session-name construction + validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_build_session_name_uses_eight_char_prefix_and_agent_id() -> None:
|
||||
assert (
|
||||
_build_session_name("07c4844b-abcdef1234567890", "claude")
|
||||
== "sessions-agent-07c4844b-claude"
|
||||
)
|
||||
|
||||
|
||||
def test_plan_rejects_agent_id_with_shell_metachars() -> None:
|
||||
broker, _ = _broker([])
|
||||
with pytest.raises(AgentTmuxError):
|
||||
broker.plan("dev", "07c4844b", "claude; rm -rf ~", ["claude"])
|
||||
|
||||
|
||||
def test_plan_rejects_workspace_cache_key_with_space() -> None:
|
||||
broker, _ = _broker([])
|
||||
with pytest.raises(AgentTmuxError):
|
||||
broker.plan("dev", "a b c", "claude", ["claude"])
|
||||
|
||||
|
||||
def test_plan_rejects_empty_agent_cmd() -> None:
|
||||
broker, _ = _broker([])
|
||||
with pytest.raises(AgentTmuxError):
|
||||
broker.plan("dev", "07c4844b", "claude", [])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# plan() output shape
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_plan_builds_attach_argv_with_ssh_prefix() -> None:
|
||||
broker, _ = _broker([])
|
||||
session = broker.plan("dev", "07c4844bdeadbeef", "claude", ["claude"])
|
||||
assert isinstance(session, TmuxAgentSession)
|
||||
assert session.session_name == "sessions-agent-07c4844b-claude"
|
||||
assert session.attach_argv == (
|
||||
"ssh",
|
||||
"-F",
|
||||
"/fake/config",
|
||||
"dev",
|
||||
"tmux",
|
||||
"attach",
|
||||
"-t",
|
||||
"sessions-agent-07c4844b-claude",
|
||||
)
|
||||
|
||||
|
||||
def test_plan_builds_spawn_argv_as_bash_lc_new_session_command() -> None:
|
||||
broker, _ = _broker([])
|
||||
session = broker.plan("dev", "07c4844bdeadbeef", "claude", ["claude", "--verbose"])
|
||||
assert session.spawn_argv[:6] == (
|
||||
"ssh",
|
||||
"-F",
|
||||
"/fake/config",
|
||||
"dev",
|
||||
"bash",
|
||||
"-lc",
|
||||
)
|
||||
remote_cmd = session.spawn_argv[6]
|
||||
assert remote_cmd.startswith(
|
||||
"tmux new-session -A -d -s sessions-agent-07c4844b-claude -- "
|
||||
)
|
||||
assert "claude --verbose" in remote_cmd
|
||||
# ``</dev/null`` is required so tmux 3.x doesn't probe the inherited
|
||||
# stdin and emit ``open terminal failed: not a terminal`` even with
|
||||
# ``-d``. See agent_tmux.plan() commentary.
|
||||
assert remote_cmd.endswith(" </dev/null")
|
||||
|
||||
|
||||
def test_plan_default_ssh_builder_passes_dash_T_to_disable_pty() -> None:
|
||||
"""The shipped broker must explicitly disable PTY allocation.
|
||||
|
||||
OpenSSH's default of "no TTY when a remote command is given" is fine
|
||||
on the happy path, but a stray ``RequestTTY=yes`` in the user's
|
||||
``~/.ssh/config`` (or ``Host *`` block) would otherwise force a PTY
|
||||
and recreate the original ``not a terminal`` failure even with
|
||||
``-d``. The broker pins ``-T`` to make the no-TTY contract explicit.
|
||||
"""
|
||||
from sessions.agent_tmux import _default_ssh_command_builder
|
||||
|
||||
assert _default_ssh_command_builder("aws-celery") == [
|
||||
"ssh",
|
||||
"-T",
|
||||
"aws-celery",
|
||||
]
|
||||
|
||||
|
||||
def test_plan_expands_tilde_paths_in_agent_cmd() -> None:
|
||||
broker, _ = _broker([])
|
||||
session = broker.plan(
|
||||
"dev", "07c4844bdeadbeef", "claude", ["~/bin/claude", "--model=opus"]
|
||||
)
|
||||
remote_cmd = session.spawn_argv[6]
|
||||
# ``~/bin/claude`` should have been rewritten to ``"$HOME/bin/claude"`` so
|
||||
# the remote shell expands $HOME rather than treating ``~`` as a literal.
|
||||
assert '"$HOME/bin/claude"' in remote_cmd
|
||||
assert "--model=opus" in remote_cmd
|
||||
|
||||
|
||||
def test_plan_result_is_frozen() -> None:
|
||||
broker, _ = _broker([])
|
||||
session = broker.plan("dev", "07c4844b", "claude", ["claude"])
|
||||
with pytest.raises(FrozenInstanceError):
|
||||
session.session_name = "other" # type: ignore[misc]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# is_running
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_is_running_returns_true_on_zero_exit() -> None:
|
||||
broker, run = _broker([(0, "", "")])
|
||||
assert broker.is_running("dev", "sessions-agent-07c4844b-claude") is True
|
||||
assert run.calls[0][-4:] == [
|
||||
"tmux",
|
||||
"has-session",
|
||||
"-t",
|
||||
"sessions-agent-07c4844b-claude",
|
||||
]
|
||||
|
||||
|
||||
def test_is_running_returns_false_on_nonzero_exit() -> None:
|
||||
broker, _ = _broker([(1, "", "can't find session")])
|
||||
assert broker.is_running("dev", "sessions-agent-07c4844b-claude") is False
|
||||
|
||||
|
||||
def test_is_running_returns_false_when_tmux_missing() -> None:
|
||||
broker, _ = _broker([(127, "", "tmux: command not found")])
|
||||
assert broker.is_running("dev", "sessions-agent-07c4844b-claude") is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# attach_or_spawn
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_attach_or_spawn_is_noop_when_already_running() -> None:
|
||||
broker, run = _broker([(0, "", "")])
|
||||
session = broker.plan("dev", "07c4844bdeadbeef", "claude", ["claude"])
|
||||
broker.attach_or_spawn(session)
|
||||
# Only the has-session probe ran; no second call to spawn.
|
||||
assert len(run.calls) == 1
|
||||
assert run.calls[0][-3:-1] == ["has-session", "-t"]
|
||||
|
||||
|
||||
def test_attach_or_spawn_spawns_when_missing() -> None:
|
||||
broker, run = _broker([(1, "", "no server"), (0, "", "")])
|
||||
session = broker.plan("dev", "07c4844bdeadbeef", "claude", ["claude"])
|
||||
broker.attach_or_spawn(session)
|
||||
assert len(run.calls) == 2
|
||||
spawn_argv = run.calls[1]
|
||||
assert spawn_argv[:6] == ["ssh", "-F", "/fake/config", "dev", "bash", "-lc"]
|
||||
remote_cmd = spawn_argv[6]
|
||||
# Both the detached-create flag AND the stdin redirect must be in
|
||||
# the spawned command; missing either causes the "open terminal
|
||||
# failed: not a terminal" regression on no-TTY hosts.
|
||||
assert "tmux new-session -A -d -s sessions-agent-07c4844b-claude" in remote_cmd
|
||||
assert remote_cmd.endswith(" </dev/null")
|
||||
|
||||
|
||||
def test_attach_or_spawn_raises_on_spawn_failure() -> None:
|
||||
broker, _ = _broker([(1, "", "no server"), (2, "", "tmux session foo bar")])
|
||||
session = broker.plan("dev", "07c4844bdeadbeef", "claude", ["claude"])
|
||||
with pytest.raises(AgentTmuxError, match="tmux spawn"):
|
||||
broker.attach_or_spawn(session)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# list_sessions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_list_sessions_filters_to_sessions_owned_prefix() -> None:
|
||||
stdout = (
|
||||
"sessions-agent-07c4844b-claude\n"
|
||||
"sessions-agent-a75c7f0f-codex\n"
|
||||
"random-user-session\n"
|
||||
)
|
||||
broker, _ = _broker([(0, stdout, "")])
|
||||
assert broker.list_sessions("dev") == [
|
||||
"sessions-agent-07c4844b-claude",
|
||||
"sessions-agent-a75c7f0f-codex",
|
||||
]
|
||||
|
||||
|
||||
def test_list_sessions_returns_empty_when_no_sessions() -> None:
|
||||
broker, _ = _broker([(1, "", "no server running on /tmp/tmux-1000/default")])
|
||||
assert broker.list_sessions("dev") == []
|
||||
|
||||
|
||||
def test_list_sessions_returns_empty_when_tmux_missing() -> None:
|
||||
broker, _ = _broker([(127, "", "bash: tmux: command not found")])
|
||||
assert broker.list_sessions("dev") == []
|
||||
|
||||
|
||||
def test_list_sessions_raises_on_unknown_failure() -> None:
|
||||
broker, _ = _broker([(255, "", "ssh: connection refused")])
|
||||
with pytest.raises(AgentTmuxError, match="list-sessions"):
|
||||
broker.list_sessions("dev")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# kill / shutdown_all
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_kill_tolerates_session_not_found() -> None:
|
||||
broker, _ = _broker([(1, "", "can't find session: sessions-agent-xxx")])
|
||||
broker.kill("dev", "sessions-agent-xxx-claude") # no exception
|
||||
|
||||
|
||||
def test_kill_raises_on_other_failures() -> None:
|
||||
broker, _ = _broker([(255, "", "ssh: connection refused")])
|
||||
with pytest.raises(AgentTmuxError, match="kill-session"):
|
||||
broker.kill("dev", "sessions-agent-xxx-claude")
|
||||
|
||||
|
||||
def test_shutdown_all_iterates_every_session() -> None:
|
||||
responses = [
|
||||
(
|
||||
0,
|
||||
"sessions-agent-07c4844b-claude\nsessions-agent-a75c7f0f-codex\n",
|
||||
"",
|
||||
),
|
||||
(0, "", ""), # kill 1
|
||||
(0, "", ""), # kill 2
|
||||
]
|
||||
broker, run = _broker(responses)
|
||||
broker.shutdown_all("dev")
|
||||
# One list-sessions + two kills.
|
||||
assert len(run.calls) == 3
|
||||
kill_targets = [call[-1] for call in run.calls[1:]]
|
||||
assert kill_targets == [
|
||||
"sessions-agent-07c4844b-claude",
|
||||
"sessions-agent-a75c7f0f-codex",
|
||||
]
|
||||
|
||||
|
||||
def test_shutdown_all_best_effort_on_individual_kill_failure(caplog) -> None:
|
||||
responses = [
|
||||
(0, "sessions-agent-07c4844b-claude\nsessions-agent-a75c7f0f-codex\n", ""),
|
||||
(255, "", "ssh: connection reset"), # kill 1 fails hard
|
||||
(0, "", ""), # kill 2 succeeds
|
||||
]
|
||||
broker, run = _broker(responses)
|
||||
with caplog.at_level("WARNING", logger="sessions.agent_tmux"):
|
||||
broker.shutdown_all("dev")
|
||||
# Both kills still attempted despite the first failing.
|
||||
assert len(run.calls) == 3
|
||||
assert any(
|
||||
"kill sessions-agent-07c4844b-claude" in rec.message for rec in caplog.records
|
||||
)
|
||||
|
||||
|
||||
def test_shutdown_all_best_effort_when_list_fails(caplog) -> None:
|
||||
broker, run = _broker([(255, "", "ssh: connection refused")])
|
||||
with caplog.at_level("WARNING", logger="sessions.agent_tmux"):
|
||||
broker.shutdown_all("dev")
|
||||
# list_sessions failed; no kills attempted.
|
||||
assert len(run.calls) == 1
|
||||
assert any("list_sessions" in rec.message for rec in caplog.records)
|
||||
@@ -1,213 +0,0 @@
|
||||
"""Real-subprocess smoke tests for :class:`AgentTmuxBroker`.
|
||||
|
||||
Pattern mirrors ``test_integration_remote_file_ops`` — a ``/bin/sh``
|
||||
shim stands in for ``ssh`` and translates the broker's argv into
|
||||
scripted exit codes + canned stdout so the whole broker flow runs
|
||||
end-to-end without touching a real remote host.
|
||||
|
||||
Each test spins up a fresh fake-ssh directory, constructs the broker
|
||||
with its default ``subprocess.run`` (no injected recorder), drives one
|
||||
method, and asserts against the real process exit code / stdout. No
|
||||
``subprocess.Popen`` stubs, no ``FakeLib`` — this suite's value is
|
||||
that it catches regressions the mock-only unit tests cannot, e.g. an
|
||||
argv ordering bug or a bash quoting break.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Tuple
|
||||
|
||||
import pytest
|
||||
from sessions.agent_tmux import AgentTmuxBroker, AgentTmuxError
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
sys.platform == "win32",
|
||||
reason="fake-ssh shim is /bin/sh only — Windows equivalent is Track W1.",
|
||||
)
|
||||
|
||||
|
||||
def _write_fake_ssh(
|
||||
dir_path: Path,
|
||||
*,
|
||||
has_session_exit: int = 0,
|
||||
list_sessions_stdout: str = "",
|
||||
kill_session_exit: int = 0,
|
||||
new_session_exit: int = 0,
|
||||
new_session_stderr: str = "",
|
||||
) -> Path:
|
||||
"""Install a ``/bin/sh`` ``ssh`` shim that routes broker argv to canned output.
|
||||
|
||||
The broker always calls us as::
|
||||
|
||||
ssh <alias> tmux <subcmd> [args...]
|
||||
|
||||
``<alias>`` is the first positional arg; everything after it is the
|
||||
remote command. The shim discards the alias and dispatches on the
|
||||
first remote token. Canned stdout / stderr are written to sibling
|
||||
files and ``cat``-ed out so embedded newlines survive a round-trip
|
||||
through the shell's single-line argv.
|
||||
"""
|
||||
stdout_file = dir_path / "list_sessions_stdout.txt"
|
||||
stdout_file.write_text(list_sessions_stdout, encoding="utf-8")
|
||||
stderr_file = dir_path / "spawn_stderr.txt"
|
||||
stderr_file.write_text(new_session_stderr, encoding="utf-8")
|
||||
|
||||
script = dir_path / "ssh"
|
||||
# The shim shifts past the alias, then runs a per-subcommand case.
|
||||
# Using subprocess.Popen through a /bin/sh marker keeps the classifier
|
||||
# on "real-subprocess" even if future edits drop the literal shebang.
|
||||
script.write_text(
|
||||
"#!/bin/sh\n"
|
||||
"# test shim — subprocess.Popen-equivalent routing\n"
|
||||
"# Drop any leading ssh option flags (e.g. ``-T`` to disable PTY\n"
|
||||
"# allocation) before consuming the alias positional.\n"
|
||||
"while [ $# -gt 0 ]; do\n"
|
||||
' case "$1" in\n'
|
||||
" -[A-Za-z])\n"
|
||||
" shift\n"
|
||||
" ;;\n"
|
||||
" *)\n"
|
||||
" break\n"
|
||||
" ;;\n"
|
||||
" esac\n"
|
||||
"done\n"
|
||||
"shift # drop alias\n"
|
||||
'sub=""\n'
|
||||
'if [ $# -ge 2 ]; then sub="$2"; fi\n'
|
||||
'case "$sub" in\n'
|
||||
" has-session)\n"
|
||||
f" exit {has_session_exit}\n"
|
||||
" ;;\n"
|
||||
" list-sessions)\n"
|
||||
f" cat {stdout_file}\n"
|
||||
" exit 0\n"
|
||||
" ;;\n"
|
||||
" kill-session)\n"
|
||||
f" exit {kill_session_exit}\n"
|
||||
" ;;\n"
|
||||
" new-session)\n"
|
||||
f" cat {stderr_file} >&2\n"
|
||||
f" exit {new_session_exit}\n"
|
||||
" ;;\n"
|
||||
" *)\n"
|
||||
" # bash -lc fallback for the spawn path; evaluate as shell\n"
|
||||
' if [ "$1" = "bash" ] && [ "$2" = "-lc" ]; then\n'
|
||||
f" cat {stderr_file} >&2\n"
|
||||
f" exit {new_session_exit}\n"
|
||||
" fi\n"
|
||||
' echo "unexpected tmux subcommand: $*" >&2\n'
|
||||
" exit 2\n"
|
||||
" ;;\n"
|
||||
"esac\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
script.chmod(0o755)
|
||||
return script
|
||||
|
||||
|
||||
def _with_fake_ssh_on_path(tmp_path: Path, **shim_kwargs: object) -> Tuple[Path, str]:
|
||||
"""Install the fake-ssh into ``tmp_path`` and prepend its dir to PATH.
|
||||
|
||||
Returns ``(fake_dir, saved_path)`` so the caller can restore ``PATH``.
|
||||
"""
|
||||
fake_bin = tmp_path / "fakebin"
|
||||
fake_bin.mkdir()
|
||||
_write_fake_ssh(fake_bin, **shim_kwargs) # type: ignore[arg-type]
|
||||
saved_path = os.environ.get("PATH", "")
|
||||
os.environ["PATH"] = "{}:{}".format(fake_bin, saved_path)
|
||||
return fake_bin, saved_path
|
||||
|
||||
|
||||
def test_is_running_returns_true_when_fake_ssh_exits_zero(tmp_path: Path) -> None:
|
||||
_, saved_path = _with_fake_ssh_on_path(tmp_path, has_session_exit=0)
|
||||
try:
|
||||
broker = AgentTmuxBroker()
|
||||
assert broker.is_running("dev", "sessions-agent-abc-claude") is True
|
||||
finally:
|
||||
os.environ["PATH"] = saved_path
|
||||
|
||||
|
||||
def test_is_running_returns_false_when_fake_ssh_exits_nonzero(tmp_path: Path) -> None:
|
||||
_, saved_path = _with_fake_ssh_on_path(tmp_path, has_session_exit=1)
|
||||
try:
|
||||
broker = AgentTmuxBroker()
|
||||
assert broker.is_running("dev", "sessions-agent-abc-claude") is False
|
||||
finally:
|
||||
os.environ["PATH"] = saved_path
|
||||
|
||||
|
||||
def test_list_sessions_parses_tmux_output_and_filters_prefix(tmp_path: Path) -> None:
|
||||
# Mixed output — two Sessions-owned sessions + one user session that
|
||||
# must be filtered out.
|
||||
canned = (
|
||||
"sessions-agent-deadbeef-claude\n"
|
||||
"my-manual-session\n"
|
||||
"sessions-agent-cafef00d-codex\n"
|
||||
)
|
||||
_, saved_path = _with_fake_ssh_on_path(tmp_path, list_sessions_stdout=canned)
|
||||
try:
|
||||
broker = AgentTmuxBroker()
|
||||
sessions = broker.list_sessions("dev")
|
||||
assert sessions == [
|
||||
"sessions-agent-deadbeef-claude",
|
||||
"sessions-agent-cafef00d-codex",
|
||||
]
|
||||
finally:
|
||||
os.environ["PATH"] = saved_path
|
||||
|
||||
|
||||
def test_list_sessions_empty_on_no_server_running(tmp_path: Path) -> None:
|
||||
# tmux exits 1 with "no server running" when no sessions exist — our
|
||||
# /bin/sh shim returns a well-formed 0-exit empty stdout, which the
|
||||
# broker reads as "empty session list" without raising.
|
||||
_, saved_path = _with_fake_ssh_on_path(tmp_path, list_sessions_stdout="")
|
||||
try:
|
||||
broker = AgentTmuxBroker()
|
||||
assert broker.list_sessions("dev") == []
|
||||
finally:
|
||||
os.environ["PATH"] = saved_path
|
||||
|
||||
|
||||
def test_kill_session_swallows_zero_exit(tmp_path: Path) -> None:
|
||||
_, saved_path = _with_fake_ssh_on_path(tmp_path, kill_session_exit=0)
|
||||
try:
|
||||
broker = AgentTmuxBroker()
|
||||
broker.kill("dev", "sessions-agent-abc-claude") # must not raise
|
||||
finally:
|
||||
os.environ["PATH"] = saved_path
|
||||
|
||||
|
||||
def test_attach_or_spawn_raises_on_nonzero_spawn(tmp_path: Path) -> None:
|
||||
# has-session returns 1 (not running), so attach_or_spawn proceeds to
|
||||
# the spawn path; the shim routes that to bash -lc and simulates a
|
||||
# hard failure by exit 2 + a stderr message.
|
||||
_, saved_path = _with_fake_ssh_on_path(
|
||||
tmp_path,
|
||||
has_session_exit=1,
|
||||
new_session_exit=2,
|
||||
new_session_stderr="boom",
|
||||
)
|
||||
try:
|
||||
broker = AgentTmuxBroker()
|
||||
plan = broker.plan("dev", "cache-xyz", "claude", ("claude",))
|
||||
with pytest.raises(AgentTmuxError, match="tmux spawn"):
|
||||
broker.attach_or_spawn(plan)
|
||||
finally:
|
||||
os.environ["PATH"] = saved_path
|
||||
|
||||
|
||||
def test_attach_or_spawn_is_noop_when_already_running(tmp_path: Path) -> None:
|
||||
_, saved_path = _with_fake_ssh_on_path(
|
||||
tmp_path,
|
||||
has_session_exit=0, # is_running returns True -> skip spawn
|
||||
new_session_exit=77, # would blow up if spawn actually ran
|
||||
)
|
||||
try:
|
||||
broker = AgentTmuxBroker()
|
||||
plan = broker.plan("dev", "cache-xyz", "claude", ("claude",))
|
||||
broker.attach_or_spawn(plan) # must not raise
|
||||
finally:
|
||||
os.environ["PATH"] = saved_path
|
||||
@@ -1,231 +0,0 @@
|
||||
"""Unit tests for :mod:`sessions.agent_window_layout`."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import pytest
|
||||
from sessions.agent_window_layout import (
|
||||
LAYOUT_ID_OTHER,
|
||||
LAYOUT_ID_THREE_GROUP,
|
||||
LAYOUT_ID_TWO_GROUP,
|
||||
LAYOUT_STATE_KEY,
|
||||
SessionsAgentLayoutCollapseSwitcherCommand,
|
||||
SessionsAgentLayoutCommand,
|
||||
build_three_group_layout,
|
||||
build_two_group_layout,
|
||||
current_layout_id,
|
||||
read_stored_layout_id,
|
||||
write_stored_layout_id,
|
||||
)
|
||||
|
||||
|
||||
class _FakeWindow:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
layout: Optional[Dict[str, Any]] = None,
|
||||
project_data: Optional[Dict[str, Any]] = None,
|
||||
has_set_project_data: bool = True,
|
||||
) -> None:
|
||||
self._layout = layout
|
||||
self._project_data = project_data
|
||||
self._set_layout_calls: List[Dict[str, Any]] = []
|
||||
self._set_project_data_calls: List[Dict[str, Any]] = []
|
||||
if not has_set_project_data:
|
||||
self.set_project_data = None # type: ignore[assignment]
|
||||
|
||||
def get_layout(self) -> Optional[Dict[str, Any]]:
|
||||
return self._layout
|
||||
|
||||
def set_layout(self, layout: Dict[str, Any]) -> None:
|
||||
self._set_layout_calls.append(layout)
|
||||
self._layout = layout
|
||||
|
||||
def project_data(self) -> Optional[Dict[str, Any]]:
|
||||
return self._project_data
|
||||
|
||||
def set_project_data(self, data: Dict[str, Any]) -> None: # type: ignore[no-redef]
|
||||
self._set_project_data_calls.append(data)
|
||||
self._project_data = data
|
||||
|
||||
|
||||
def test_build_three_group_layout_shape() -> None:
|
||||
layout = build_three_group_layout(0.4, 0.8)
|
||||
assert layout["cols"] == [0.0, 0.4, 0.8, 1.0]
|
||||
assert layout["rows"] == [0.0, 1.0]
|
||||
assert layout["cells"] == [[0, 0, 1, 1], [1, 0, 2, 1], [2, 0, 3, 1]]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"editor, terminus, expected_editor, expected_terminus",
|
||||
[
|
||||
(0.4, 0.8, 0.4, 0.8),
|
||||
# Inverted input — sanitizer swaps them into monotonic order.
|
||||
(0.9, 0.2, 0.85, 0.95),
|
||||
# Equal input — nudge terminus right to keep both visible.
|
||||
(0.5, 0.5, 0.5, 0.6),
|
||||
# Out-of-range clamped.
|
||||
(-0.1, 1.5, 0.05, 0.95),
|
||||
],
|
||||
)
|
||||
def test_build_three_group_layout_sanitizes_fractions(
|
||||
editor: float,
|
||||
terminus: float,
|
||||
expected_editor: float,
|
||||
expected_terminus: float,
|
||||
) -> None:
|
||||
layout = build_three_group_layout(editor, terminus)
|
||||
cols = layout["cols"]
|
||||
assert cols[0] == 0.0
|
||||
assert cols[-1] == 1.0
|
||||
assert pytest.approx(cols[1]) == expected_editor
|
||||
assert pytest.approx(cols[2]) == expected_terminus
|
||||
|
||||
|
||||
def test_build_two_group_layout_collapses_switcher() -> None:
|
||||
layout = build_two_group_layout(0.5)
|
||||
assert layout["cols"] == [0.0, 0.5, 1.0]
|
||||
assert layout["rows"] == [0.0, 1.0]
|
||||
assert layout["cells"] == [[0, 0, 1, 1], [1, 0, 2, 1]]
|
||||
|
||||
|
||||
def test_build_two_group_layout_clamps_extreme_editor_frac() -> None:
|
||||
layout = build_two_group_layout(0.0)
|
||||
assert pytest.approx(layout["cols"][1]) == 0.05
|
||||
layout = build_two_group_layout(1.2)
|
||||
assert pytest.approx(layout["cols"][1]) == 0.95
|
||||
|
||||
|
||||
def test_current_layout_id_detects_three_group_shape() -> None:
|
||||
window = _FakeWindow(layout=build_three_group_layout())
|
||||
assert current_layout_id(window) == LAYOUT_ID_THREE_GROUP
|
||||
|
||||
|
||||
def test_current_layout_id_detects_two_group_shape() -> None:
|
||||
window = _FakeWindow(layout=build_two_group_layout())
|
||||
assert current_layout_id(window) == LAYOUT_ID_TWO_GROUP
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"layout",
|
||||
[
|
||||
None,
|
||||
{},
|
||||
{"cells": "bogus", "rows": [0.0, 1.0]},
|
||||
{"cells": [[0, 0, 1, 1]], "rows": [0.0, 0.5, 1.0]}, # two rows
|
||||
# Four groups — not one of ours.
|
||||
{
|
||||
"cols": [0.0, 0.25, 0.5, 0.75, 1.0],
|
||||
"rows": [0.0, 1.0],
|
||||
"cells": [
|
||||
[0, 0, 1, 1],
|
||||
[1, 0, 2, 1],
|
||||
[2, 0, 3, 1],
|
||||
[3, 0, 4, 1],
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
def test_current_layout_id_other_for_non_matching_layouts(
|
||||
layout: Optional[Dict[str, Any]],
|
||||
) -> None:
|
||||
window = _FakeWindow(layout=layout)
|
||||
assert current_layout_id(window) == LAYOUT_ID_OTHER
|
||||
|
||||
|
||||
def test_current_layout_id_handles_missing_get_layout() -> None:
|
||||
assert current_layout_id(object()) == LAYOUT_ID_OTHER
|
||||
|
||||
|
||||
def test_current_layout_id_normalizes_tuple_cells() -> None:
|
||||
window = _FakeWindow(
|
||||
layout={
|
||||
"cols": [0.0, 0.4, 0.8, 1.0],
|
||||
"rows": [0.0, 1.0],
|
||||
"cells": [(0, 0, 1, 1), (1, 0, 2, 1), (2, 0, 3, 1)],
|
||||
}
|
||||
)
|
||||
assert current_layout_id(window) == LAYOUT_ID_THREE_GROUP
|
||||
|
||||
|
||||
def test_read_stored_layout_id_returns_none_without_project() -> None:
|
||||
assert read_stored_layout_id(_FakeWindow()) is None
|
||||
|
||||
|
||||
def test_write_and_read_stored_layout_id_round_trip() -> None:
|
||||
window = _FakeWindow(project_data={"folders": [{"path": "."}]})
|
||||
write_stored_layout_id(window, LAYOUT_ID_THREE_GROUP)
|
||||
assert window._project_data is not None
|
||||
assert window._project_data["settings"][LAYOUT_STATE_KEY] == LAYOUT_ID_THREE_GROUP
|
||||
assert read_stored_layout_id(window) == LAYOUT_ID_THREE_GROUP
|
||||
|
||||
|
||||
def test_write_stored_layout_id_preserves_unrelated_settings() -> None:
|
||||
window = _FakeWindow(
|
||||
project_data={"settings": {"unrelated": "keep"}, "folders": []}
|
||||
)
|
||||
write_stored_layout_id(window, LAYOUT_ID_TWO_GROUP)
|
||||
assert window._project_data is not None
|
||||
settings = window._project_data["settings"]
|
||||
assert settings["unrelated"] == "keep"
|
||||
assert settings[LAYOUT_STATE_KEY] == LAYOUT_ID_TWO_GROUP
|
||||
assert window._project_data["folders"] == []
|
||||
|
||||
|
||||
def test_write_stored_layout_id_noop_without_setter() -> None:
|
||||
window = _FakeWindow(has_set_project_data=False)
|
||||
# Should not raise.
|
||||
write_stored_layout_id(window, LAYOUT_ID_THREE_GROUP)
|
||||
|
||||
|
||||
def test_session_agent_layout_command_applies_three_group_and_persists() -> None:
|
||||
window = _FakeWindow(project_data={})
|
||||
command = SessionsAgentLayoutCommand.__new__(SessionsAgentLayoutCommand)
|
||||
command.window = window # type: ignore[attr-defined]
|
||||
command.run(editor_frac=0.4, terminus_frac=0.8)
|
||||
assert len(window._set_layout_calls) == 1
|
||||
assert window._set_layout_calls[0]["cells"] == [
|
||||
[0, 0, 1, 1],
|
||||
[1, 0, 2, 1],
|
||||
[2, 0, 3, 1],
|
||||
]
|
||||
assert read_stored_layout_id(window) == LAYOUT_ID_THREE_GROUP
|
||||
|
||||
|
||||
def test_session_agent_layout_collapse_command_applies_two_group_and_persists() -> None:
|
||||
window = _FakeWindow(project_data={})
|
||||
command = SessionsAgentLayoutCollapseSwitcherCommand.__new__(
|
||||
SessionsAgentLayoutCollapseSwitcherCommand
|
||||
)
|
||||
command.window = window # type: ignore[attr-defined]
|
||||
command.run(editor_frac=0.5)
|
||||
assert len(window._set_layout_calls) == 1
|
||||
assert window._set_layout_calls[0]["cells"] == [
|
||||
[0, 0, 1, 1],
|
||||
[1, 0, 2, 1],
|
||||
]
|
||||
assert read_stored_layout_id(window) == LAYOUT_ID_TWO_GROUP
|
||||
|
||||
|
||||
def test_session_agent_layout_command_noop_without_set_layout() -> None:
|
||||
class _StubWindow:
|
||||
def project_data(self) -> Dict[str, Any]:
|
||||
return {}
|
||||
|
||||
def set_project_data(self, data: Dict[str, Any]) -> None:
|
||||
raise AssertionError("should not persist when set_layout is missing")
|
||||
|
||||
command = SessionsAgentLayoutCommand.__new__(SessionsAgentLayoutCommand)
|
||||
command.window = _StubWindow() # type: ignore[attr-defined]
|
||||
# Should not raise even though the fake window lacks set_layout.
|
||||
command.run()
|
||||
|
||||
|
||||
def test_fake_window_helpers_self_consistent() -> None:
|
||||
# Guard against accidental drift in the test double.
|
||||
window = _FakeWindow(project_data={"settings": {}})
|
||||
write_stored_layout_id(window, LAYOUT_ID_THREE_GROUP)
|
||||
assert len(window._set_project_data_calls) == 1
|
||||
data: Tuple[str, ...] = tuple(window._set_project_data_calls[0]["settings"].keys())
|
||||
assert LAYOUT_STATE_KEY in data
|
||||
@@ -1,319 +0,0 @@
|
||||
from pathlib import Path
|
||||
|
||||
from sessions.agent_window import (
|
||||
DEFAULT_AGENT_WINDOW_LAYOUT,
|
||||
AgentTimelineEntry,
|
||||
AgentWindowLayoutSpec,
|
||||
AgentWindowRegion,
|
||||
DiffProposalRef,
|
||||
EditorJumpTarget,
|
||||
SessionAvailability,
|
||||
StructuredActionSummary,
|
||||
TimelineEntryKind,
|
||||
build_agent_session_rows,
|
||||
build_agent_window_view_state,
|
||||
collect_jump_targets_from_timeline,
|
||||
example_structured_helper_summary,
|
||||
load_agent_window_view_state_from_recent_store,
|
||||
trim_timeline_for_long_history,
|
||||
)
|
||||
from sessions.recent_state import (
|
||||
RecentWorkspace,
|
||||
RecentWorkspaceIndex,
|
||||
RecentWorkspaceStore,
|
||||
)
|
||||
|
||||
|
||||
def test_layout_encodes_issue_g_three_pane_decision() -> None:
|
||||
spec = DEFAULT_AGENT_WINDOW_LAYOUT
|
||||
assert spec.ordered_regions() == (
|
||||
AgentWindowRegion.LEFT_SESSIONS,
|
||||
AgentWindowRegion.CENTER_ACTIVITY,
|
||||
AgentWindowRegion.RIGHT_WORKSPACE,
|
||||
)
|
||||
assert spec.summary_first is True
|
||||
assert spec.avoid_full_workbench is True
|
||||
|
||||
|
||||
def test_layout_spec_is_customizable() -> None:
|
||||
custom = AgentWindowLayoutSpec(summary_first=False)
|
||||
assert custom.summary_first is False
|
||||
|
||||
|
||||
def test_session_rows_use_cache_key_identity_for_same_host_root_profiles() -> None:
|
||||
cache_root = Path("/cache")
|
||||
python_profile = RecentWorkspace(
|
||||
"prod",
|
||||
"/srv/app",
|
||||
"key-python",
|
||||
"2026-04-11T12:00:00+00:00",
|
||||
)
|
||||
default_profile = RecentWorkspace(
|
||||
"prod",
|
||||
"/srv/app",
|
||||
"key-default",
|
||||
"2026-04-11T11:00:00+00:00",
|
||||
)
|
||||
rows = build_agent_session_rows(
|
||||
(python_profile, default_profile),
|
||||
cache_root,
|
||||
now_epoch_seconds=1_700_000_000,
|
||||
live_session_ids={},
|
||||
)
|
||||
assert len(rows) == 2
|
||||
assert {r.cache_key for r in rows} == {"key-python", "key-default"}
|
||||
assert any(r.disambiguation_hint for r in rows)
|
||||
|
||||
|
||||
def test_connected_session_overrides_stale_recency() -> None:
|
||||
old = "2020-01-01T00:00:00+00:00"
|
||||
entry = RecentWorkspace("prod", "/srv/app", "ck1", old)
|
||||
rows = build_agent_session_rows(
|
||||
(entry,),
|
||||
Path("/cache"),
|
||||
now_epoch_seconds=1_700_000_000,
|
||||
live_session_ids={"ck1": True},
|
||||
)
|
||||
assert rows[0].availability == SessionAvailability.CONNECTED
|
||||
|
||||
|
||||
def test_missing_cache_row(tmp_path: Path) -> None:
|
||||
cache_root = tmp_path / "cache"
|
||||
entry = RecentWorkspace("prod", "/srv/app", "missing", "2026-04-11T12:00:00+00:00")
|
||||
rows = build_agent_session_rows(
|
||||
(entry,),
|
||||
cache_root,
|
||||
now_epoch_seconds=1_700_000_000,
|
||||
live_session_ids={},
|
||||
)
|
||||
assert rows[0].availability == SessionAvailability.CACHE_MISSING
|
||||
|
||||
|
||||
def test_foreign_shared_cache_when_dir_exists(tmp_path: Path) -> None:
|
||||
cache_root = tmp_path / "cache"
|
||||
key = "ck-foreign"
|
||||
(cache_root / key).mkdir(parents=True)
|
||||
entry = RecentWorkspace("prod", "/srv/app", key, "2026-04-11T12:00:00+00:00")
|
||||
rows = build_agent_session_rows(
|
||||
(entry,),
|
||||
cache_root,
|
||||
now_epoch_seconds=1_700_000_000,
|
||||
live_session_ids={},
|
||||
cache_origin_host_by_key={key: "laptop-a"},
|
||||
current_host_name="laptop-b",
|
||||
)
|
||||
assert rows[0].availability == SessionAvailability.FOREIGN_SHARED_CACHE
|
||||
|
||||
|
||||
def test_offline_assumed_for_recent_cache(tmp_path: Path) -> None:
|
||||
cache_root = tmp_path / "cache"
|
||||
key = "ck-online-shape"
|
||||
(cache_root / key).mkdir(parents=True)
|
||||
entry = RecentWorkspace(
|
||||
"prod",
|
||||
"/srv/app",
|
||||
key,
|
||||
"2026-04-11T12:00:00+00:00",
|
||||
)
|
||||
rows = build_agent_session_rows(
|
||||
(entry,),
|
||||
cache_root,
|
||||
now_epoch_seconds=1_700_000_000,
|
||||
live_session_ids={},
|
||||
)
|
||||
assert rows[0].availability == SessionAvailability.OFFLINE_ASSUMED
|
||||
|
||||
|
||||
def test_stale_metadata_for_old_timestamp(tmp_path: Path) -> None:
|
||||
cache_root = tmp_path / "cache"
|
||||
key = "ck-old"
|
||||
(cache_root / key).mkdir(parents=True)
|
||||
entry = RecentWorkspace("prod", "/srv/app", key, "2020-01-01T00:00:00+00:00")
|
||||
rows = build_agent_session_rows(
|
||||
(entry,),
|
||||
cache_root,
|
||||
now_epoch_seconds=1_700_000_000,
|
||||
live_session_ids={},
|
||||
)
|
||||
assert rows[0].availability == SessionAvailability.STALE_METADATA
|
||||
|
||||
|
||||
def test_trim_timeline_truncates_count_and_body() -> None:
|
||||
many = tuple(
|
||||
AgentTimelineEntry(
|
||||
entry_id=str(i),
|
||||
timestamp_iso=f"2026-04-11T12:{i:02d}:00+00:00",
|
||||
kind=TimelineEntryKind.USER_CHAT,
|
||||
title="t",
|
||||
body_summary="x" * 100,
|
||||
)
|
||||
for i in range(10)
|
||||
)
|
||||
trimmed = trim_timeline_for_long_history(many, max_entries=3, max_body_chars=20)
|
||||
assert len(trimmed) == 3
|
||||
assert trimmed[0].entry_id == "7"
|
||||
assert len(trimmed[-1].body_summary) <= 20
|
||||
|
||||
|
||||
def test_diff_proposal_stale_when_mtime_differs() -> None:
|
||||
proposal = DiffProposalRef(
|
||||
proposal_id="p1",
|
||||
paths=("/srv/app/a.py",),
|
||||
source_snapshot_mtime_ns=100,
|
||||
current_source_mtime_ns=200,
|
||||
)
|
||||
assert proposal.is_stale() is True
|
||||
|
||||
|
||||
def test_diff_proposal_not_stale_when_unknown_mtime() -> None:
|
||||
proposal = DiffProposalRef("p1", ("/srv/a.py",), None, 99)
|
||||
assert proposal.is_stale() is False
|
||||
|
||||
|
||||
def test_structured_action_summary_not_raw_terminal() -> None:
|
||||
summary = example_structured_helper_summary()
|
||||
assert summary.verb
|
||||
assert summary.stderr_preview is None
|
||||
assert isinstance(summary, StructuredActionSummary)
|
||||
|
||||
|
||||
def test_collect_jump_targets_dedupes() -> None:
|
||||
p = Path("/cache/file.py")
|
||||
entries = (
|
||||
AgentTimelineEntry(
|
||||
"1",
|
||||
"2026-04-11T00:00:00+00:00",
|
||||
TimelineEntryKind.HELPER_ACTION,
|
||||
"a",
|
||||
"b",
|
||||
jump=EditorJumpTarget(p, line_one_based=2),
|
||||
),
|
||||
AgentTimelineEntry(
|
||||
"2",
|
||||
"2026-04-11T00:00:01+00:00",
|
||||
TimelineEntryKind.HELPER_ACTION,
|
||||
"a",
|
||||
"b",
|
||||
jump=EditorJumpTarget(p, line_one_based=2),
|
||||
),
|
||||
)
|
||||
jumps = collect_jump_targets_from_timeline(entries)
|
||||
assert len(jumps) == 1
|
||||
|
||||
|
||||
def test_view_state_replaces_timeline_when_cache_missing(tmp_path: Path) -> None:
|
||||
cache_root = tmp_path / "cache"
|
||||
key = "absent"
|
||||
entry = RecentWorkspace("prod", "/srv/app", key, "2026-04-11T12:00:00+00:00")
|
||||
noisy = tuple(
|
||||
AgentTimelineEntry(
|
||||
str(i),
|
||||
"2026-04-11T12:00:00+00:00",
|
||||
TimelineEntryKind.CLI_ACTION,
|
||||
"raw",
|
||||
"pretend this is terminal spam",
|
||||
)
|
||||
for i in range(5)
|
||||
)
|
||||
state = build_agent_window_view_state(
|
||||
(entry,),
|
||||
cache_root,
|
||||
now_epoch_seconds=1_700_000_000,
|
||||
selected_session_id=key,
|
||||
raw_timeline=noisy,
|
||||
)
|
||||
assert len(state.timeline_entries) == 1
|
||||
assert state.timeline_entries[0].kind == TimelineEntryKind.SYSTEM_EVENT
|
||||
assert state.directory_pane is None
|
||||
|
||||
|
||||
def test_view_state_prepends_foreign_shared_warning(tmp_path: Path) -> None:
|
||||
cache_root = tmp_path / "cache"
|
||||
key = "shared"
|
||||
(cache_root / key).mkdir(parents=True)
|
||||
entry = RecentWorkspace("prod", "/srv/app", key, "2026-04-11T12:00:00+00:00")
|
||||
body = AgentTimelineEntry(
|
||||
"1",
|
||||
"2026-04-11T12:00:00+00:00",
|
||||
TimelineEntryKind.ASSISTANT_SUMMARY,
|
||||
"done",
|
||||
"summary-first result",
|
||||
)
|
||||
state = build_agent_window_view_state(
|
||||
(entry,),
|
||||
cache_root,
|
||||
now_epoch_seconds=1_700_000_000,
|
||||
selected_session_id=key,
|
||||
raw_timeline=(body,),
|
||||
cache_origin_host_by_key={key: "other"},
|
||||
current_host_name="here",
|
||||
)
|
||||
assert state.timeline_entries[0].entry_id == "foreign-shared-cache"
|
||||
assert state.timeline_entries[1] == body
|
||||
assert state.directory_pane is not None
|
||||
|
||||
|
||||
def test_load_view_state_from_recent_workspace_store(tmp_path: Path) -> None:
|
||||
path = tmp_path / "recent-workspaces.json"
|
||||
store = RecentWorkspaceStore(path)
|
||||
entry = RecentWorkspace("prod", "/srv/z", "z9", "2026-04-11T12:00:00+00:00")
|
||||
store.save_index(RecentWorkspaceIndex((entry,)))
|
||||
cache_root = tmp_path / "cache"
|
||||
(cache_root / "z9").mkdir(parents=True)
|
||||
state = load_agent_window_view_state_from_recent_store(
|
||||
store,
|
||||
cache_root,
|
||||
now_epoch_seconds=1_700_000_000,
|
||||
selected_session_id="z9",
|
||||
raw_timeline=(),
|
||||
)
|
||||
assert len(state.session_rows) == 1
|
||||
assert state.session_rows[0].host_alias == "prod"
|
||||
|
||||
|
||||
def test_timeline_links_structured_action_and_diff(tmp_path: Path) -> None:
|
||||
cache_root = tmp_path / "cache"
|
||||
key = "k1"
|
||||
(cache_root / key).mkdir(parents=True)
|
||||
entry = RecentWorkspace("prod", "/srv/app", key, "2026-04-11T12:00:00+00:00")
|
||||
local_file = cache_root / key / "a.py"
|
||||
local_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
local_file.write_text("x", encoding="utf-8")
|
||||
action = StructuredActionSummary(
|
||||
verb="Run ruff",
|
||||
target_remote_path="/srv/app/a.py",
|
||||
exit_code=0,
|
||||
duration_ms=120,
|
||||
stderr_preview="none",
|
||||
stdout_line_count=14,
|
||||
notes="stdout hidden in summary-first mode",
|
||||
)
|
||||
diff = DiffProposalRef(
|
||||
"d1",
|
||||
("/srv/app/a.py",),
|
||||
source_snapshot_mtime_ns=1,
|
||||
current_source_mtime_ns=1,
|
||||
)
|
||||
timeline = (
|
||||
AgentTimelineEntry(
|
||||
"e1",
|
||||
"2026-04-11T12:00:00+00:00",
|
||||
TimelineEntryKind.CLI_ACTION,
|
||||
"Ruff",
|
||||
"Fixed import order in a.py",
|
||||
action=action,
|
||||
jump=EditorJumpTarget(local_file, remote_path="/srv/app/a.py"),
|
||||
diff=diff,
|
||||
),
|
||||
)
|
||||
state = build_agent_window_view_state(
|
||||
(entry,),
|
||||
cache_root,
|
||||
now_epoch_seconds=1_700_000_000,
|
||||
selected_session_id=key,
|
||||
raw_timeline=timeline,
|
||||
)
|
||||
row = state.session_rows[0]
|
||||
assert row.display_title.startswith("prod:")
|
||||
assert state.directory_pane is not None
|
||||
assert str(state.directory_pane.root_local_cache_path) == str(cache_root / key)
|
||||
@@ -398,24 +398,22 @@ def test_connect_selected_workspace_opens_remote_tree_view(
|
||||
assert refresh_calls == [1]
|
||||
|
||||
|
||||
def test_open_remote_terminal_uses_workspace_host_and_root(
|
||||
def test_open_remote_terminal_invokes_new_terminal_with_ssh_cd_invocation(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
"""Resolves workspace alias + remote root and dispatches to ``new_terminal``.
|
||||
|
||||
The terminal lifetime now lives in the OS terminal (no embedded view, no
|
||||
tmux convenience layer). The command's only job is to assemble the
|
||||
``ssh -t`` invocation that lands the user in their remote root and hand
|
||||
it off to Sublime's ``new_terminal`` (provided by the user-installed
|
||||
``Terminal`` package).
|
||||
"""
|
||||
ssh_config_path = tmp_path / "config"
|
||||
_write_ssh_config(ssh_config_path, "Host prod\n HostName prod.example.com\n")
|
||||
settings = SessionsSettings(ssh_config_path=ssh_config_path)
|
||||
monkeypatch.setattr(commands, "SessionsSettings", lambda: settings)
|
||||
monkeypatch.setattr(commands.sublime, "cache_path", lambda: str(tmp_path / "cache"))
|
||||
monkeypatch.setattr(
|
||||
commands.sublime,
|
||||
"find_resources",
|
||||
lambda pattern: (),
|
||||
raising=False,
|
||||
)
|
||||
# When tmux is absent on the remote, Sessions falls back to the
|
||||
# pre-C2 direct-shell spawn so the no-Terminus branch still works
|
||||
# on hosts without tmux installed.
|
||||
monkeypatch.setitem(commands._TERMINUS_TMUX_AVAILABLE_BY_HOST, "prod", False)
|
||||
recent_store = commands._recent_store(settings)
|
||||
recent_store.save_index(
|
||||
RecentWorkspaceIndex(
|
||||
@@ -435,124 +433,14 @@ def test_open_remote_terminal_uses_workspace_host_and_root(
|
||||
|
||||
commands.SessionsOpenRemoteTerminalCommand(window).run()
|
||||
|
||||
expected_cmd = (
|
||||
"ssh -tt prod 'cd /srv/app && "
|
||||
"(stty sane -ixon 2>/dev/null || true) && exec bash -il'"
|
||||
new_terminal_calls = [c for c in window.window_commands if c[0] == "new_terminal"]
|
||||
assert len(new_terminal_calls) == 1
|
||||
args = new_terminal_calls[0][1]
|
||||
assert args["cmd"] == ("ssh -t prod 'cd /srv/app && exec ${SHELL:-/bin/sh} -l'")
|
||||
assert "cwd" in args
|
||||
assert any(
|
||||
"opening external terminal for prod:/srv/app" in m for m in status_messages
|
||||
)
|
||||
assert ("new_terminal", {"cmd": expected_cmd}) in window.window_commands
|
||||
assert "Sessions terminal attached to prod /srv/app" in status_messages[-1]
|
||||
|
||||
|
||||
def test_open_remote_terminal_prefers_terminus_panel(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
ssh_config_path = tmp_path / "config"
|
||||
_write_ssh_config(ssh_config_path, "Host prod\n HostName prod.example.com\n")
|
||||
settings = SessionsSettings(ssh_config_path=ssh_config_path)
|
||||
monkeypatch.setattr(commands, "SessionsSettings", lambda: settings)
|
||||
monkeypatch.setattr(commands.sublime, "cache_path", lambda: str(tmp_path / "cache"))
|
||||
monkeypatch.setattr(
|
||||
commands.sublime,
|
||||
"find_resources",
|
||||
lambda pattern: (
|
||||
("Packages/Terminus/Terminus.sublime-settings",)
|
||||
if "Terminus" in pattern
|
||||
else ()
|
||||
),
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setitem(commands._TERMINUS_TMUX_AVAILABLE_BY_HOST, "prod", False)
|
||||
recent_store = commands._recent_store(settings)
|
||||
recent_store.save_index(
|
||||
RecentWorkspaceIndex(
|
||||
(
|
||||
RecentWorkspace(
|
||||
"prod",
|
||||
"/srv/app",
|
||||
"cache-123",
|
||||
"2026-04-12T03:00:00+00:00",
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
status_messages: List[str] = []
|
||||
monkeypatch.setattr(commands.sublime, "status_message", status_messages.append)
|
||||
window = FakeWindow(project_data={"settings": {PROJECT_SETTINGS_KEY: "cache-123"}})
|
||||
|
||||
commands.SessionsOpenRemoteTerminalCommand(window).run()
|
||||
|
||||
terminus_calls = [c for c in window.window_commands if c[0] == "terminus_open"]
|
||||
assert len(terminus_calls) == 1
|
||||
args = terminus_calls[0][1]
|
||||
assert args["show_in_panel"] is True
|
||||
assert args["panel_name"] == "Terminus"
|
||||
assert args["auto_close"] is False
|
||||
assert args["cmd"] == [
|
||||
"ssh",
|
||||
"-tt",
|
||||
"prod",
|
||||
"cd /srv/app && (stty sane -ixon 2>/dev/null || true) && exec bash -il",
|
||||
]
|
||||
|
||||
|
||||
def test_open_remote_terminal_uses_configured_shell_command(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
ssh_config_path = tmp_path / "config"
|
||||
_write_ssh_config(ssh_config_path, "Host prod\n HostName prod.example.com\n")
|
||||
settings = SessionsSettings(ssh_config_path=ssh_config_path)
|
||||
monkeypatch.setattr(commands, "SessionsSettings", lambda: settings)
|
||||
monkeypatch.setattr(commands.sublime, "cache_path", lambda: str(tmp_path / "cache"))
|
||||
monkeypatch.setattr(
|
||||
commands.sublime,
|
||||
"find_resources",
|
||||
lambda pattern: (
|
||||
("Packages/Terminus/Terminus.sublime-settings",)
|
||||
if "Terminus" in pattern
|
||||
else ()
|
||||
),
|
||||
raising=False,
|
||||
)
|
||||
|
||||
class _SettingsObj:
|
||||
def get(self, key: str, default=None):
|
||||
if key == "sessions_remote_terminal_shell":
|
||||
return "zsh -l"
|
||||
return default
|
||||
|
||||
monkeypatch.setattr(
|
||||
commands.sublime,
|
||||
"load_settings",
|
||||
lambda _: _SettingsObj(),
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setitem(commands._TERMINUS_TMUX_AVAILABLE_BY_HOST, "prod", False)
|
||||
recent_store = commands._recent_store(settings)
|
||||
recent_store.save_index(
|
||||
RecentWorkspaceIndex(
|
||||
(
|
||||
RecentWorkspace(
|
||||
"prod",
|
||||
"/srv/app",
|
||||
"cache-123",
|
||||
"2026-04-12T03:00:00+00:00",
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
window = FakeWindow(project_data={"settings": {PROJECT_SETTINGS_KEY: "cache-123"}})
|
||||
|
||||
commands.SessionsOpenRemoteTerminalCommand(window).run()
|
||||
|
||||
terminus_calls = [c for c in window.window_commands if c[0] == "terminus_open"]
|
||||
assert len(terminus_calls) == 1
|
||||
args = terminus_calls[0][1]
|
||||
assert args["cmd"] == [
|
||||
"ssh",
|
||||
"-tt",
|
||||
"prod",
|
||||
"cd /srv/app && (stty sane -ixon 2>/dev/null || true) && exec zsh -l",
|
||||
]
|
||||
|
||||
|
||||
def test_connected_host_alias_recovers_from_persisted_state(
|
||||
|
||||
@@ -1,276 +0,0 @@
|
||||
"""Wiring tests for ``SessionsOpenRemoteTerminalCommand`` (Track C2).
|
||||
|
||||
These tests live separately from ``test_cmd_connect.py`` because they
|
||||
exercise the tmux-persistence and view-reuse paths introduced in
|
||||
v0.5.8. The existing tmux-off fallback behaviour is still asserted from
|
||||
``test_cmd_connect.py`` so coverage doesn't regress.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, List
|
||||
|
||||
import pytest
|
||||
from conftest import FakeWindow, _write_ssh_config
|
||||
from sessions import commands
|
||||
from sessions.recent_state import RecentWorkspace, RecentWorkspaceIndex
|
||||
from sessions.settings_model import SessionsSettings
|
||||
from sessions.workspace_state import PROJECT_SETTINGS_KEY
|
||||
|
||||
|
||||
def _seed_recent_workspace(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
*,
|
||||
host_alias: str = "prod",
|
||||
remote_root: str = "/srv/app",
|
||||
cache_key: str = "cache-123",
|
||||
has_terminus: bool = True,
|
||||
) -> SessionsSettings:
|
||||
ssh_config_path = tmp_path / "config"
|
||||
_write_ssh_config(
|
||||
ssh_config_path,
|
||||
"Host {alias}\n HostName {alias}.example.com\n".format(alias=host_alias),
|
||||
)
|
||||
settings = SessionsSettings(ssh_config_path=ssh_config_path)
|
||||
monkeypatch.setattr(commands, "SessionsSettings", lambda: settings)
|
||||
monkeypatch.setattr(
|
||||
commands.sublime,
|
||||
"cache_path",
|
||||
lambda: str(tmp_path / "cache"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
commands.sublime,
|
||||
"find_resources",
|
||||
lambda pattern: (
|
||||
("Packages/Terminus/Terminus.sublime-settings",)
|
||||
if has_terminus and "Terminus" in pattern
|
||||
else ()
|
||||
),
|
||||
raising=False,
|
||||
)
|
||||
recent_store = commands._recent_store(settings)
|
||||
recent_store.save_index(
|
||||
RecentWorkspaceIndex(
|
||||
(
|
||||
RecentWorkspace(
|
||||
host_alias,
|
||||
remote_root,
|
||||
cache_key,
|
||||
"2026-04-12T03:00:00+00:00",
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
return settings
|
||||
|
||||
|
||||
class _TerminusMarkedView:
|
||||
"""View stub that carries the ``terminus_view`` settings marker."""
|
||||
|
||||
_next_id = 5000
|
||||
|
||||
def __init__(self, *, live: bool = True) -> None:
|
||||
self._live = live
|
||||
self._id = _TerminusMarkedView._next_id
|
||||
_TerminusMarkedView._next_id += 1
|
||||
|
||||
def id(self) -> int:
|
||||
return self._id
|
||||
|
||||
def settings(self) -> "_TerminusSettings":
|
||||
return _TerminusSettings(self._live)
|
||||
|
||||
def close(self) -> None:
|
||||
self._live = False
|
||||
|
||||
|
||||
class _TerminusSettings:
|
||||
def __init__(self, live: bool) -> None:
|
||||
self._live = live
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
if key == "terminus_view":
|
||||
return self._live
|
||||
return default
|
||||
|
||||
|
||||
def test_terminus_branch_wraps_shell_in_tmux_when_available(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
_seed_recent_workspace(tmp_path, monkeypatch)
|
||||
monkeypatch.setitem(commands._TERMINUS_TMUX_AVAILABLE_BY_HOST, "prod", True)
|
||||
status: List[str] = []
|
||||
monkeypatch.setattr(commands.sublime, "status_message", status.append)
|
||||
window = FakeWindow(project_data={"settings": {PROJECT_SETTINGS_KEY: "cache-123"}})
|
||||
|
||||
commands.SessionsOpenRemoteTerminalCommand(window).run()
|
||||
|
||||
terminus_calls = [c for c in window.window_commands if c[0] == "terminus_open"]
|
||||
assert len(terminus_calls) == 1
|
||||
args = terminus_calls[0][1]
|
||||
# Argv unchanged (still ``ssh -tt prod <remote>``); the remote
|
||||
# invocation is what's wrapped in tmux.
|
||||
assert args["cmd"][:3] == ["ssh", "-tt", "prod"]
|
||||
remote_cmd = args["cmd"][3]
|
||||
# No leading ``exec`` inside the tmux argv — tmux itself becomes
|
||||
# the session's initial program and spawns ``bash -il`` directly.
|
||||
assert remote_cmd == (
|
||||
"cd /srv/app && (stty sane -ixon 2>/dev/null || true) && "
|
||||
"tmux new-session -A -s 'sessions-term-prod' bash -il"
|
||||
)
|
||||
assert status[-1] == "Sessions terminal attached to prod /srv/app"
|
||||
|
||||
|
||||
def test_probe_runs_once_per_host_and_caches(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
_seed_recent_workspace(tmp_path, monkeypatch)
|
||||
probe_calls: List[str] = []
|
||||
|
||||
from sessions import terminal_tmux_session as tts
|
||||
|
||||
def stub_probe(host_alias: str, **_kwargs) -> tts.TmuxProbeResult:
|
||||
probe_calls.append(host_alias)
|
||||
return tts.TmuxProbeResult(
|
||||
available=True,
|
||||
exit_code=0,
|
||||
stdout="/usr/bin/tmux",
|
||||
stderr="",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(commands, "probe_tmux_available", stub_probe)
|
||||
|
||||
window1 = FakeWindow(project_data={"settings": {PROJECT_SETTINGS_KEY: "cache-123"}})
|
||||
window2 = FakeWindow(project_data={"settings": {PROJECT_SETTINGS_KEY: "cache-123"}})
|
||||
# First call probes.
|
||||
commands.SessionsOpenRemoteTerminalCommand(window1).run()
|
||||
# Second call for the same host must not re-probe.
|
||||
commands.SessionsOpenRemoteTerminalCommand(window2).run()
|
||||
assert probe_calls == ["prod"]
|
||||
|
||||
|
||||
def test_probe_missing_tmux_falls_back_and_emits_hint(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
_seed_recent_workspace(tmp_path, monkeypatch)
|
||||
from sessions import terminal_tmux_session as tts
|
||||
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"probe_tmux_available",
|
||||
lambda host_alias, **_: tts.TmuxProbeResult(
|
||||
available=False,
|
||||
exit_code=127,
|
||||
stdout="",
|
||||
stderr="command not found",
|
||||
),
|
||||
)
|
||||
status: List[str] = []
|
||||
monkeypatch.setattr(commands.sublime, "status_message", status.append)
|
||||
window = FakeWindow(project_data={"settings": {PROJECT_SETTINGS_KEY: "cache-123"}})
|
||||
|
||||
commands.SessionsOpenRemoteTerminalCommand(window).run()
|
||||
|
||||
terminus_calls = [c for c in window.window_commands if c[0] == "terminus_open"]
|
||||
assert len(terminus_calls) == 1
|
||||
remote_cmd = terminus_calls[0][1]["cmd"][3]
|
||||
# Falls back to the pre-C2 direct-shell invocation when tmux is
|
||||
# missing on the remote.
|
||||
assert remote_cmd == (
|
||||
"cd /srv/app && (stty sane -ixon 2>/dev/null || true) && exec bash -il"
|
||||
)
|
||||
# User-visible hint for the missing tmux binary.
|
||||
assert any("tmux not found on prod" in msg for msg in status)
|
||||
|
||||
|
||||
def test_reuses_live_terminus_view_without_second_spawn(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
_seed_recent_workspace(tmp_path, monkeypatch)
|
||||
monkeypatch.setitem(commands._TERMINUS_TMUX_AVAILABLE_BY_HOST, "prod", True)
|
||||
window = FakeWindow(project_data={"settings": {PROJECT_SETTINGS_KEY: "cache-123"}})
|
||||
# Seed a live Terminus view for prod.
|
||||
live_view = _TerminusMarkedView(live=True)
|
||||
window.created_views.append(live_view) # type: ignore[arg-type]
|
||||
commands._TERMINUS_VIEW_BY_HOST["prod"] = live_view
|
||||
|
||||
status: List[str] = []
|
||||
monkeypatch.setattr(commands.sublime, "status_message", status.append)
|
||||
|
||||
commands.SessionsOpenRemoteTerminalCommand(window).run()
|
||||
|
||||
# No ``terminus_open`` was issued — we refocused the live view.
|
||||
assert not any(c[0] == "terminus_open" for c in window.window_commands)
|
||||
assert window.active_view_value is live_view
|
||||
assert status[-1] == "Sessions terminal for prod refocused"
|
||||
|
||||
|
||||
def test_closed_cached_view_is_evicted_and_spawns_new(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
_seed_recent_workspace(tmp_path, monkeypatch)
|
||||
monkeypatch.setitem(commands._TERMINUS_TMUX_AVAILABLE_BY_HOST, "prod", True)
|
||||
window = FakeWindow(project_data={"settings": {PROJECT_SETTINGS_KEY: "cache-123"}})
|
||||
# Cached view whose settings report the view is no longer a
|
||||
# Terminus pane (simulates the user closing the tab).
|
||||
dead_view = _TerminusMarkedView(live=False)
|
||||
commands._TERMINUS_VIEW_BY_HOST["prod"] = dead_view
|
||||
|
||||
# Present a fresh view in the window's ``views()`` list so the
|
||||
# post-spawn registration step can pick it up.
|
||||
fresh_view = _TerminusMarkedView(live=True)
|
||||
window.created_views.append(fresh_view) # type: ignore[arg-type]
|
||||
|
||||
commands.SessionsOpenRemoteTerminalCommand(window).run()
|
||||
|
||||
# Dead view evicted.
|
||||
assert commands._TERMINUS_VIEW_BY_HOST.get("prod") is fresh_view
|
||||
# terminus_open fired for the re-attach.
|
||||
assert any(c[0] == "terminus_open" for c in window.window_commands)
|
||||
|
||||
|
||||
def test_registers_fresh_terminus_view_after_spawn(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
_seed_recent_workspace(tmp_path, monkeypatch)
|
||||
monkeypatch.setitem(commands._TERMINUS_TMUX_AVAILABLE_BY_HOST, "prod", True)
|
||||
window = FakeWindow(project_data={"settings": {PROJECT_SETTINGS_KEY: "cache-123"}})
|
||||
newly_opened = _TerminusMarkedView(live=True)
|
||||
window.created_views.append(newly_opened) # type: ignore[arg-type]
|
||||
|
||||
commands.SessionsOpenRemoteTerminalCommand(window).run()
|
||||
|
||||
assert commands._TERMINUS_VIEW_BY_HOST.get("prod") is newly_opened
|
||||
|
||||
|
||||
def test_no_terminus_branch_still_spawns_tmux_wrapped_shell(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
# No Terminus available → ``new_terminal`` fallback. tmux wrapping
|
||||
# still happens when the probe succeeds.
|
||||
_seed_recent_workspace(tmp_path, monkeypatch, has_terminus=False)
|
||||
monkeypatch.setitem(commands._TERMINUS_TMUX_AVAILABLE_BY_HOST, "prod", True)
|
||||
window = FakeWindow(project_data={"settings": {PROJECT_SETTINGS_KEY: "cache-123"}})
|
||||
|
||||
commands.SessionsOpenRemoteTerminalCommand(window).run()
|
||||
|
||||
terminal_calls = [c for c in window.window_commands if c[0] == "new_terminal"]
|
||||
assert len(terminal_calls) == 1
|
||||
# The ``new_terminal`` cmd is a single shell-quoted string; the
|
||||
# tmux-wrapped remote invocation must be embedded inside.
|
||||
cmd = terminal_calls[0][1]["cmd"]
|
||||
assert "tmux new-session -A -s" in cmd
|
||||
assert "sessions-term-prod" in cmd
|
||||
|
||||
|
||||
def test_prefix_is_disjoint_from_agent_tmux_prefix() -> None:
|
||||
# Guard against accidental collision between Track C2 (terminal)
|
||||
# and Track D (agent) tmux session namespaces.
|
||||
from sessions.agent_tmux import _SESSION_NAME_PREFIX as AGENT_PREFIX
|
||||
from sessions.terminal_tmux_session import SESSION_NAME_PREFIX as TERMINAL_PREFIX
|
||||
|
||||
assert AGENT_PREFIX != TERMINAL_PREFIX
|
||||
assert not AGENT_PREFIX.startswith(TERMINAL_PREFIX)
|
||||
assert not TERMINAL_PREFIX.startswith(AGENT_PREFIX)
|
||||
@@ -683,151 +683,6 @@ def test_status_listener_handles_short_timeout_ms_zero_passthrough(monkeypatch)
|
||||
invalidate_version_cache()
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# SessionsRegisterJupyterKernelCommand (Phase B)
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
class _FakeJupyterManager:
|
||||
"""Records register_kernelspec_only calls; optionally raises to simulate failure."""
|
||||
|
||||
def __init__(self, *, raises: Optional[Exception] = None) -> None:
|
||||
self.calls: List[Dict[str, Any]] = []
|
||||
self._raises = raises
|
||||
|
||||
def register_kernelspec_only(
|
||||
self,
|
||||
host_alias: str,
|
||||
kernel_python: str,
|
||||
kernel_name: str,
|
||||
) -> None:
|
||||
self.calls.append(
|
||||
{
|
||||
"host_alias": host_alias,
|
||||
"kernel_python": kernel_python,
|
||||
"kernel_name": kernel_name,
|
||||
}
|
||||
)
|
||||
if self._raises is not None:
|
||||
raise self._raises
|
||||
|
||||
|
||||
def _install_register_patches(
|
||||
monkeypatch,
|
||||
*,
|
||||
manager: _FakeJupyterManager,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Patch the module-level helpers that the register-kernel path drives.
|
||||
|
||||
Returns a list that accumulates invocations for inspection: each entry is
|
||||
one of ``{"status": <ConnectStatus>}``, ``{"task": (args, kwargs)}``, or
|
||||
``{"message": <str>}``.
|
||||
"""
|
||||
events: List[Dict[str, Any]] = []
|
||||
monkeypatch.setattr(commands, "_jupyter_session_manager", lambda: manager)
|
||||
monkeypatch.setattr(
|
||||
commands, "_status_message", lambda msg: events.append({"message": msg})
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"_run_in_background",
|
||||
lambda fn, *args, **kwargs: (
|
||||
events.append({"task": (args, kwargs)}) or fn(*args)
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
commands, "_set_timeout", lambda cb, delay=0: cb() if callable(cb) else None
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"_emit_status",
|
||||
lambda status: events.append({"status": status}),
|
||||
)
|
||||
return events
|
||||
|
||||
|
||||
def test_register_jupyter_kernel_requires_active_python(monkeypatch) -> None:
|
||||
window = FakeWindow(project_data={"settings": {}})
|
||||
manager = _FakeJupyterManager()
|
||||
events = _install_register_patches(monkeypatch, manager=manager)
|
||||
monkeypatch.setattr(commands, "_workspace_context", lambda *a, **kw: _ctx())
|
||||
|
||||
commands.SessionsRegisterJupyterKernelCommand(window).run()
|
||||
|
||||
assert manager.calls == []
|
||||
messages = [e["message"] for e in events if "message" in e]
|
||||
assert any("select Python interpreter" in msg for msg in messages), messages
|
||||
|
||||
|
||||
def test_register_jupyter_kernel_requires_workspace_context(monkeypatch) -> None:
|
||||
window = FakeWindow(
|
||||
project_data={
|
||||
"settings": {
|
||||
"sessions_active_python_interpreter": "/srv/ws/.venv/bin/python",
|
||||
},
|
||||
},
|
||||
)
|
||||
manager = _FakeJupyterManager()
|
||||
_install_register_patches(monkeypatch, manager=manager)
|
||||
monkeypatch.setattr(commands, "_workspace_context", lambda *a, **kw: None)
|
||||
|
||||
commands.SessionsRegisterJupyterKernelCommand(window).run()
|
||||
|
||||
assert manager.calls == []
|
||||
|
||||
|
||||
def test_register_jupyter_kernel_success_runs_manager_and_emits_ready(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
window = FakeWindow(
|
||||
project_data={
|
||||
"settings": {
|
||||
"sessions_active_python_interpreter": "/srv/ws/.venv/bin/python",
|
||||
},
|
||||
},
|
||||
)
|
||||
manager = _FakeJupyterManager()
|
||||
events = _install_register_patches(monkeypatch, manager=manager)
|
||||
monkeypatch.setattr(commands, "_workspace_context", lambda *a, **kw: _ctx())
|
||||
|
||||
commands.SessionsRegisterJupyterKernelCommand(window).run()
|
||||
|
||||
assert len(manager.calls) == 1
|
||||
call = manager.calls[0]
|
||||
assert call["host_alias"] == "prod"
|
||||
assert call["kernel_python"] == "/srv/ws/.venv/bin/python"
|
||||
assert call["kernel_name"] == "sessions-cache-py"
|
||||
|
||||
ready = [
|
||||
e["status"] for e in events if "status" in e and e["status"].kind == "ready"
|
||||
]
|
||||
assert ready, events
|
||||
assert "Registered Jupyter kernel sessions-cache-py" in ready[0].detail
|
||||
|
||||
|
||||
def test_register_jupyter_kernel_failure_emits_warning(monkeypatch) -> None:
|
||||
from sessions.jupyter_hosting import JupyterHostingError
|
||||
|
||||
window = FakeWindow(
|
||||
project_data={
|
||||
"settings": {
|
||||
"sessions_active_python_interpreter": "/srv/ws/.venv/bin/python",
|
||||
},
|
||||
},
|
||||
)
|
||||
manager = _FakeJupyterManager(raises=JupyterHostingError("nope"))
|
||||
events = _install_register_patches(monkeypatch, manager=manager)
|
||||
monkeypatch.setattr(commands, "_workspace_context", lambda *a, **kw: _ctx())
|
||||
|
||||
commands.SessionsRegisterJupyterKernelCommand(window).run()
|
||||
|
||||
warnings = [
|
||||
e["status"] for e in events if "status" in e and e["status"].kind == "warning"
|
||||
]
|
||||
assert warnings, events
|
||||
assert "Kernel registration failed on prod" in warnings[0].detail
|
||||
|
||||
|
||||
def test_python_interpreter_status_listener_without_window() -> None:
|
||||
listener = commands.SessionsPythonInterpreterStatusListener()
|
||||
view = _StatusView(None)
|
||||
|
||||
@@ -1,507 +0,0 @@
|
||||
"""Wiring tests for the multi-pane remote-terminal commands (Cluster E).
|
||||
|
||||
Covers ``SessionsNewRemoteTerminalPaneCommand`` (numbered tmux session
|
||||
spawn) and ``SessionsKillRemoteTerminalCommand`` (quick-panel + remote
|
||||
``tmux kill-session``). The persistent-reattach behaviour of the main
|
||||
``Sessions: Open Remote Terminal`` command is still exercised from
|
||||
``test_cmd_open_remote_terminal.py``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, List, Tuple
|
||||
|
||||
import pytest
|
||||
from conftest import FakeWindow, _write_ssh_config
|
||||
from sessions import commands
|
||||
from sessions.recent_state import RecentWorkspace, RecentWorkspaceIndex
|
||||
from sessions.settings_model import SessionsSettings
|
||||
from sessions.workspace_state import PROJECT_SETTINGS_KEY
|
||||
|
||||
|
||||
def _seed_recent_workspace(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
*,
|
||||
host_alias: str = "prod",
|
||||
remote_root: str = "/srv/app",
|
||||
cache_key: str = "cache-123",
|
||||
has_terminus: bool = True,
|
||||
) -> SessionsSettings:
|
||||
ssh_config_path = tmp_path / "config"
|
||||
_write_ssh_config(
|
||||
ssh_config_path,
|
||||
"Host {alias}\n HostName {alias}.example.com\n".format(alias=host_alias),
|
||||
)
|
||||
settings = SessionsSettings(ssh_config_path=ssh_config_path)
|
||||
monkeypatch.setattr(commands, "SessionsSettings", lambda: settings)
|
||||
monkeypatch.setattr(
|
||||
commands.sublime,
|
||||
"cache_path",
|
||||
lambda: str(tmp_path / "cache"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
commands.sublime,
|
||||
"find_resources",
|
||||
lambda pattern: (
|
||||
("Packages/Terminus/Terminus.sublime-settings",)
|
||||
if has_terminus and "Terminus" in pattern
|
||||
else ()
|
||||
),
|
||||
raising=False,
|
||||
)
|
||||
recent_store = commands._recent_store(settings)
|
||||
recent_store.save_index(
|
||||
RecentWorkspaceIndex(
|
||||
(
|
||||
RecentWorkspace(
|
||||
host_alias,
|
||||
remote_root,
|
||||
cache_key,
|
||||
"2026-04-12T03:00:00+00:00",
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
return settings
|
||||
|
||||
|
||||
class _TerminusMarkedView:
|
||||
"""View stub that carries the ``terminus_view`` settings marker."""
|
||||
|
||||
_next_id = 9000
|
||||
|
||||
def __init__(self, *, live: bool = True) -> None:
|
||||
self._live = live
|
||||
self._id = _TerminusMarkedView._next_id
|
||||
_TerminusMarkedView._next_id += 1
|
||||
|
||||
def id(self) -> int:
|
||||
return self._id
|
||||
|
||||
def settings(self) -> "_TerminusSettings":
|
||||
return _TerminusSettings(self._live)
|
||||
|
||||
def close(self) -> None:
|
||||
self._live = False
|
||||
|
||||
|
||||
class _TerminusSettings:
|
||||
def __init__(self, live: bool) -> None:
|
||||
self._live = live
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
if key == "terminus_view":
|
||||
return self._live
|
||||
return default
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_terminal_caches(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Clear cross-test state so caches don't leak between tests."""
|
||||
commands._TERMINUS_VIEW_BY_HOST.clear()
|
||||
commands._TERMINUS_VIEW_BY_SESSION_NAME.clear()
|
||||
commands._TERMINUS_TMUX_AVAILABLE_BY_HOST.clear()
|
||||
|
||||
|
||||
# --- SessionsNewRemoteTerminalPaneCommand -----------------------------------
|
||||
|
||||
|
||||
def test_new_pane_picks_first_numbered_session_when_only_base_running(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
_seed_recent_workspace(tmp_path, monkeypatch)
|
||||
monkeypatch.setitem(commands._TERMINUS_TMUX_AVAILABLE_BY_HOST, "prod", True)
|
||||
|
||||
listed: List[str] = []
|
||||
|
||||
def stub_list(host_alias: str, **_: Any) -> List[str]:
|
||||
listed.append(host_alias)
|
||||
return ["sessions-term-prod"]
|
||||
|
||||
monkeypatch.setattr(commands, "list_terminal_sessions", stub_list)
|
||||
status: List[str] = []
|
||||
monkeypatch.setattr(commands.sublime, "status_message", status.append)
|
||||
window = FakeWindow(project_data={"settings": {PROJECT_SETTINGS_KEY: "cache-123"}})
|
||||
|
||||
commands.SessionsNewRemoteTerminalPaneCommand(window).run()
|
||||
|
||||
terminus_calls = [c for c in window.window_commands if c[0] == "terminus_open"]
|
||||
assert len(terminus_calls) == 1
|
||||
cmd = terminus_calls[0][1]["cmd"]
|
||||
assert cmd[:3] == ["ssh", "-tt", "prod"]
|
||||
remote_invocation = cmd[3]
|
||||
# Numbered session name must land verbatim in the tmux argv.
|
||||
assert "tmux new-session -A -s 'sessions-term-prod-2' bash -il" in remote_invocation
|
||||
title = terminus_calls[0][1]["title"]
|
||||
assert title.endswith("(#2)")
|
||||
assert listed == ["prod"]
|
||||
|
||||
|
||||
def test_new_pane_skips_used_indices(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
_seed_recent_workspace(tmp_path, monkeypatch)
|
||||
monkeypatch.setitem(commands._TERMINUS_TMUX_AVAILABLE_BY_HOST, "prod", True)
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"list_terminal_sessions",
|
||||
lambda host_alias, **_: [
|
||||
"sessions-term-prod",
|
||||
"sessions-term-prod-2",
|
||||
"sessions-term-prod-3",
|
||||
],
|
||||
)
|
||||
monkeypatch.setattr(commands.sublime, "status_message", lambda _msg: None)
|
||||
window = FakeWindow(project_data={"settings": {PROJECT_SETTINGS_KEY: "cache-123"}})
|
||||
|
||||
commands.SessionsNewRemoteTerminalPaneCommand(window).run()
|
||||
|
||||
terminus_calls = [c for c in window.window_commands if c[0] == "terminus_open"]
|
||||
assert len(terminus_calls) == 1
|
||||
remote_invocation = terminus_calls[0][1]["cmd"][3]
|
||||
assert "sessions-term-prod-4" in remote_invocation
|
||||
|
||||
|
||||
def test_new_pane_does_not_register_per_host_view(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
# Numbered panes must not overwrite the per-host cache; otherwise a
|
||||
# later ``Open`` would refocus a numbered tab instead of the base.
|
||||
_seed_recent_workspace(tmp_path, monkeypatch)
|
||||
monkeypatch.setitem(commands._TERMINUS_TMUX_AVAILABLE_BY_HOST, "prod", True)
|
||||
monkeypatch.setattr(commands, "list_terminal_sessions", lambda *a, **k: [])
|
||||
monkeypatch.setattr(commands.sublime, "status_message", lambda _msg: None)
|
||||
window = FakeWindow(project_data={"settings": {PROJECT_SETTINGS_KEY: "cache-123"}})
|
||||
fresh = _TerminusMarkedView(live=True)
|
||||
window.created_views.append(fresh) # type: ignore[arg-type]
|
||||
|
||||
commands.SessionsNewRemoteTerminalPaneCommand(window).run()
|
||||
|
||||
assert "prod" not in commands._TERMINUS_VIEW_BY_HOST
|
||||
# But the per-session cache *is* populated so kill can find it.
|
||||
assert commands._TERMINUS_VIEW_BY_SESSION_NAME.get("sessions-term-prod-2") is fresh
|
||||
|
||||
|
||||
def test_new_pane_falls_back_to_direct_shell_when_tmux_missing(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
# When tmux is unavailable the new-pane command must not call
|
||||
# ``list-sessions`` and must use the direct-shell fallback shape.
|
||||
_seed_recent_workspace(tmp_path, monkeypatch)
|
||||
monkeypatch.setitem(commands._TERMINUS_TMUX_AVAILABLE_BY_HOST, "prod", False)
|
||||
|
||||
list_calls: List[str] = []
|
||||
|
||||
def fail_list(host_alias: str, **_: Any) -> List[str]:
|
||||
list_calls.append(host_alias)
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(commands, "list_terminal_sessions", fail_list)
|
||||
monkeypatch.setattr(commands.sublime, "status_message", lambda _msg: None)
|
||||
window = FakeWindow(project_data={"settings": {PROJECT_SETTINGS_KEY: "cache-123"}})
|
||||
|
||||
commands.SessionsNewRemoteTerminalPaneCommand(window).run()
|
||||
|
||||
assert list_calls == [] # never bothered the remote
|
||||
terminus_calls = [c for c in window.window_commands if c[0] == "terminus_open"]
|
||||
assert len(terminus_calls) == 1
|
||||
remote_invocation = terminus_calls[0][1]["cmd"][3]
|
||||
assert "tmux" not in remote_invocation
|
||||
assert "exec bash -il" in remote_invocation
|
||||
|
||||
|
||||
# --- SessionsKillRemoteTerminalCommand --------------------------------------
|
||||
|
||||
|
||||
def test_kill_command_lists_terminal_sessions_in_quick_panel(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
_seed_recent_workspace(tmp_path, monkeypatch)
|
||||
monkeypatch.setitem(commands._TERMINUS_TMUX_AVAILABLE_BY_HOST, "prod", True)
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"list_terminal_sessions",
|
||||
lambda host_alias, **_: [
|
||||
"sessions-term-prod-3",
|
||||
"sessions-term-prod",
|
||||
"sessions-term-prod-2",
|
||||
"sessions-agent-abc-claude", # unrelated, must be filtered out.
|
||||
"sessions-term-other", # different host, must be filtered out.
|
||||
],
|
||||
)
|
||||
monkeypatch.setattr(commands.sublime, "status_message", lambda _msg: None)
|
||||
window = FakeWindow(project_data={"settings": {PROJECT_SETTINGS_KEY: "cache-123"}})
|
||||
|
||||
commands.SessionsKillRemoteTerminalCommand(window).run()
|
||||
|
||||
assert len(window.quick_panels) == 1
|
||||
items = window.quick_panels[0]
|
||||
captions = [row[0] for row in items]
|
||||
assert captions == [
|
||||
"sessions-term-prod",
|
||||
"sessions-term-prod-2",
|
||||
"sessions-term-prod-3",
|
||||
]
|
||||
|
||||
|
||||
def test_kill_command_runs_kill_session_argv_on_select(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
_seed_recent_workspace(tmp_path, monkeypatch)
|
||||
monkeypatch.setitem(commands._TERMINUS_TMUX_AVAILABLE_BY_HOST, "prod", True)
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"list_terminal_sessions",
|
||||
lambda host_alias, **_: [
|
||||
"sessions-term-prod",
|
||||
"sessions-term-prod-2",
|
||||
],
|
||||
)
|
||||
monkeypatch.setattr(commands.sublime, "_sessions_test_sync", True, raising=False)
|
||||
monkeypatch.setattr(commands.sublime, "status_message", lambda _msg: None)
|
||||
captured: List[Tuple[str, str]] = []
|
||||
|
||||
class _Completed:
|
||||
def __init__(self) -> None:
|
||||
self.returncode = 0
|
||||
self.stderr = ""
|
||||
self.stdout = ""
|
||||
|
||||
def stub_kill(host_alias: str, session_name: str, **_: Any) -> _Completed:
|
||||
captured.append((host_alias, session_name))
|
||||
return _Completed()
|
||||
|
||||
monkeypatch.setattr(commands, "kill_terminal_session", stub_kill)
|
||||
|
||||
# Cache a Terminus view for the session being killed so we can
|
||||
# assert it's closed.
|
||||
target_view = _TerminusMarkedView(live=True)
|
||||
commands._TERMINUS_VIEW_BY_SESSION_NAME["sessions-term-prod-2"] = target_view
|
||||
window = FakeWindow(project_data={"settings": {PROJECT_SETTINGS_KEY: "cache-123"}})
|
||||
|
||||
commands.SessionsKillRemoteTerminalCommand(window).run()
|
||||
assert len(window.quick_panel_callbacks) == 1
|
||||
on_select = window.quick_panel_callbacks[0]
|
||||
# Pick ``sessions-term-prod-2`` (the second sorted entry).
|
||||
on_select(1)
|
||||
|
||||
assert captured == [("prod", "sessions-term-prod-2")]
|
||||
# View was closed and evicted from the cache.
|
||||
assert "sessions-term-prod-2" not in commands._TERMINUS_VIEW_BY_SESSION_NAME
|
||||
assert target_view._live is False
|
||||
|
||||
|
||||
def test_kill_command_emits_status_when_no_sessions_running(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
_seed_recent_workspace(tmp_path, monkeypatch)
|
||||
monkeypatch.setitem(commands._TERMINUS_TMUX_AVAILABLE_BY_HOST, "prod", True)
|
||||
monkeypatch.setattr(commands, "list_terminal_sessions", lambda host_alias, **_: [])
|
||||
status: List[str] = []
|
||||
monkeypatch.setattr(commands.sublime, "status_message", status.append)
|
||||
window = FakeWindow(project_data={"settings": {PROJECT_SETTINGS_KEY: "cache-123"}})
|
||||
|
||||
commands.SessionsKillRemoteTerminalCommand(window).run()
|
||||
|
||||
assert window.quick_panels == [] # never opened
|
||||
assert any("no remote terminal sessions on prod" in m for m in status)
|
||||
|
||||
|
||||
def test_kill_command_warns_when_tmux_missing(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
_seed_recent_workspace(tmp_path, monkeypatch)
|
||||
monkeypatch.setitem(commands._TERMINUS_TMUX_AVAILABLE_BY_HOST, "prod", False)
|
||||
list_calls: List[str] = []
|
||||
|
||||
def fail_list(host_alias: str, **_: Any) -> List[str]:
|
||||
list_calls.append(host_alias)
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(commands, "list_terminal_sessions", fail_list)
|
||||
status: List[str] = []
|
||||
monkeypatch.setattr(commands.sublime, "status_message", status.append)
|
||||
window = FakeWindow(project_data={"settings": {PROJECT_SETTINGS_KEY: "cache-123"}})
|
||||
|
||||
commands.SessionsKillRemoteTerminalCommand(window).run()
|
||||
|
||||
assert list_calls == [] # short-circuits before listing.
|
||||
assert window.quick_panels == []
|
||||
assert any("tmux is not available on prod" in m for m in status)
|
||||
|
||||
|
||||
def test_kill_command_handles_already_gone_session_message(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
_seed_recent_workspace(tmp_path, monkeypatch)
|
||||
monkeypatch.setitem(commands._TERMINUS_TMUX_AVAILABLE_BY_HOST, "prod", True)
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"list_terminal_sessions",
|
||||
lambda host_alias, **_: ["sessions-term-prod-7"],
|
||||
)
|
||||
monkeypatch.setattr(commands.sublime, "_sessions_test_sync", True, raising=False)
|
||||
status: List[str] = []
|
||||
monkeypatch.setattr(commands.sublime, "status_message", status.append)
|
||||
|
||||
class _Completed:
|
||||
def __init__(self) -> None:
|
||||
self.returncode = 1
|
||||
self.stderr = "can't find session: sessions-term-prod-7"
|
||||
self.stdout = ""
|
||||
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"kill_terminal_session",
|
||||
lambda host_alias, session_name, **_: _Completed(),
|
||||
)
|
||||
window = FakeWindow(project_data={"settings": {PROJECT_SETTINGS_KEY: "cache-123"}})
|
||||
|
||||
commands.SessionsKillRemoteTerminalCommand(window).run()
|
||||
on_select = window.quick_panel_callbacks[0]
|
||||
on_select(0)
|
||||
|
||||
assert any("was already gone" in m for m in status)
|
||||
|
||||
|
||||
# --- SessionsAttachRemoteTmuxCommand ----------------------------------------
|
||||
|
||||
|
||||
def test_attach_command_lists_every_remote_tmux_session_in_quick_panel(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Quick panel must include foreign tmux sessions, not just sessions-term-*.
|
||||
|
||||
The whole point of the attach command vs. kill is that it reaches
|
||||
across the SESSION_NAME_PREFIX boundary so the user can attach to
|
||||
whatever they spun up outside Sessions.
|
||||
"""
|
||||
_seed_recent_workspace(tmp_path, monkeypatch)
|
||||
monkeypatch.setitem(commands._TERMINUS_TMUX_AVAILABLE_BY_HOST, "prod", True)
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"list_all_remote_tmux_sessions",
|
||||
lambda host_alias, **_: [
|
||||
"sessions-term-prod",
|
||||
"work", # foreign — must appear.
|
||||
"sessions-agent-abc-claude", # also foreign-style for attach purposes.
|
||||
"0", # default tmux numeric session — must appear.
|
||||
],
|
||||
)
|
||||
monkeypatch.setattr(commands.sublime, "status_message", lambda _msg: None)
|
||||
window = FakeWindow(project_data={"settings": {PROJECT_SETTINGS_KEY: "cache-123"}})
|
||||
|
||||
commands.SessionsAttachRemoteTmuxCommand(window).run()
|
||||
|
||||
assert len(window.quick_panels) == 1
|
||||
items = window.quick_panels[0]
|
||||
captions = [row[0] for row in items]
|
||||
descriptions = [row[1] for row in items]
|
||||
assert captions == [
|
||||
"sessions-term-prod",
|
||||
"work",
|
||||
"sessions-agent-abc-claude",
|
||||
"0",
|
||||
]
|
||||
# First entry is Sessions-owned; the rest are flagged "foreign" so the user
|
||||
# knows what they're attaching to.
|
||||
assert descriptions[0].startswith("Sessions-owned")
|
||||
assert all(desc.startswith("foreign") for desc in descriptions[1:])
|
||||
|
||||
|
||||
def test_attach_command_runs_terminus_open_with_attach_argv_on_select(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
_seed_recent_workspace(tmp_path, monkeypatch)
|
||||
monkeypatch.setitem(commands._TERMINUS_TMUX_AVAILABLE_BY_HOST, "prod", True)
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"list_all_remote_tmux_sessions",
|
||||
lambda host_alias, **_: ["work", "sessions-term-prod"],
|
||||
)
|
||||
monkeypatch.setattr(commands.sublime, "status_message", lambda _msg: None)
|
||||
window = FakeWindow(project_data={"settings": {PROJECT_SETTINGS_KEY: "cache-123"}})
|
||||
|
||||
commands.SessionsAttachRemoteTmuxCommand(window).run()
|
||||
on_select = window.quick_panel_callbacks[0]
|
||||
# Pick the foreign "work" session.
|
||||
on_select(0)
|
||||
|
||||
terminus_calls = [c for c in window.window_commands if c[0] == "terminus_open"]
|
||||
assert len(terminus_calls) == 1
|
||||
args = terminus_calls[0][1]
|
||||
# cmd is ["ssh", "-tt", "prod", "tmux attach-session -t work"]
|
||||
assert args["cmd"][:3] == ["ssh", "-tt", "prod"]
|
||||
assert "tmux attach-session -t" in args["cmd"][3]
|
||||
assert "work" in args["cmd"][3]
|
||||
# Title surfaces both pieces of context the user needs to identify the pane.
|
||||
assert "work" in args["title"]
|
||||
assert "prod" in args["title"]
|
||||
|
||||
|
||||
def test_attach_command_does_not_register_view_in_sessions_owned_caches(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Foreign attach must not appear in the Sessions-owned per-host /
|
||||
per-session view caches — those caches drive Sessions's own kill /
|
||||
reattach flows, and a foreign session must stay out of that scope.
|
||||
"""
|
||||
_seed_recent_workspace(tmp_path, monkeypatch)
|
||||
monkeypatch.setitem(commands._TERMINUS_TMUX_AVAILABLE_BY_HOST, "prod", True)
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"list_all_remote_tmux_sessions",
|
||||
lambda host_alias, **_: ["work"],
|
||||
)
|
||||
monkeypatch.setattr(commands.sublime, "status_message", lambda _msg: None)
|
||||
window = FakeWindow(project_data={"settings": {PROJECT_SETTINGS_KEY: "cache-123"}})
|
||||
|
||||
commands.SessionsAttachRemoteTmuxCommand(window).run()
|
||||
on_select = window.quick_panel_callbacks[0]
|
||||
on_select(0)
|
||||
|
||||
assert "prod" not in commands._TERMINUS_VIEW_BY_HOST
|
||||
assert "work" not in commands._TERMINUS_VIEW_BY_SESSION_NAME
|
||||
|
||||
|
||||
def test_attach_command_emits_status_when_no_remote_sessions(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
_seed_recent_workspace(tmp_path, monkeypatch)
|
||||
monkeypatch.setitem(commands._TERMINUS_TMUX_AVAILABLE_BY_HOST, "prod", True)
|
||||
monkeypatch.setattr(
|
||||
commands, "list_all_remote_tmux_sessions", lambda host_alias, **_: []
|
||||
)
|
||||
status: List[str] = []
|
||||
monkeypatch.setattr(commands.sublime, "status_message", status.append)
|
||||
window = FakeWindow(project_data={"settings": {PROJECT_SETTINGS_KEY: "cache-123"}})
|
||||
|
||||
commands.SessionsAttachRemoteTmuxCommand(window).run()
|
||||
|
||||
assert window.quick_panels == []
|
||||
assert any("no remote tmux sessions running on prod" in m for m in status)
|
||||
|
||||
|
||||
def test_attach_command_warns_when_tmux_missing(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
_seed_recent_workspace(tmp_path, monkeypatch)
|
||||
monkeypatch.setitem(commands._TERMINUS_TMUX_AVAILABLE_BY_HOST, "prod", False)
|
||||
list_calls: List[str] = []
|
||||
|
||||
def fail_list(host_alias: str, **_: Any) -> List[str]:
|
||||
list_calls.append(host_alias)
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(commands, "list_all_remote_tmux_sessions", fail_list)
|
||||
status: List[str] = []
|
||||
monkeypatch.setattr(commands.sublime, "status_message", status.append)
|
||||
window = FakeWindow(project_data={"settings": {PROJECT_SETTINGS_KEY: "cache-123"}})
|
||||
|
||||
commands.SessionsAttachRemoteTmuxCommand(window).run()
|
||||
|
||||
assert list_calls == [] # short-circuits before listing.
|
||||
assert window.quick_panels == []
|
||||
assert any("tmux is not available on prod" in m for m in status)
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
@@ -27,7 +26,6 @@ from sessions.settings_model import (
|
||||
ToolchainOverride,
|
||||
)
|
||||
from sessions.ssh_file_transport import RemoteExecOnceResult
|
||||
from sessions.ssh_runner import SshRunResult
|
||||
from sessions.workspace_state import PROJECT_SETTINGS_KEY
|
||||
|
||||
|
||||
@@ -381,144 +379,6 @@ def test_remote_python_tool_prepare_rejects_unknown_kind(
|
||||
assert "kind must be" in status_messages[-1]
|
||||
|
||||
|
||||
def test_preview_remote_agent_payload_renders_output_panel(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
settings = SessionsSettings(ssh_config_path=tmp_path / "config")
|
||||
monkeypatch.setattr(commands, "SessionsSettings", lambda: settings)
|
||||
monkeypatch.setattr(commands.sublime, "cache_path", lambda: str(tmp_path / "cache"))
|
||||
status_messages: List[str] = []
|
||||
monkeypatch.setattr(commands.sublime, "status_message", status_messages.append)
|
||||
recent_store = commands._recent_store(settings)
|
||||
recent_store.save_index(
|
||||
RecentWorkspaceIndex(
|
||||
(
|
||||
RecentWorkspace(
|
||||
"prod",
|
||||
"/srv/ws",
|
||||
"cache-123",
|
||||
"2026-04-12T03:00:00+00:00",
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
window = FakeWindow(project_data={"settings": {PROJECT_SETTINGS_KEY: "cache-123"}})
|
||||
payload_text = json.dumps(
|
||||
{
|
||||
"kind": "sessions.agent_editor_preview",
|
||||
"schema_version": 1,
|
||||
"title": "Preview Title",
|
||||
"unified_diff": "--- a/x\n+++ b/x\n@@\n+hello",
|
||||
"target_remote_path": "/srv/ws/x.py",
|
||||
}
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"run_ssh_remote_command",
|
||||
lambda host_alias, remote_argv, timeout_s=30.0: SshRunResult(
|
||||
0, payload_text, "", ("ssh", host_alias, remote_argv[-1])
|
||||
),
|
||||
)
|
||||
|
||||
commands.SessionsPreviewRemoteAgentPayloadCommand(window).run(
|
||||
remote_command="cat /tmp/payload.json"
|
||||
)
|
||||
|
||||
panel = window.output_panels["sessions_remote_agent_payload"]
|
||||
assert "Preview Title" in panel.content
|
||||
assert "/srv/ws/x.py" in panel.content
|
||||
assert "Unified Diff" in panel.content
|
||||
msg = status_messages[-1]
|
||||
assert "Sessions ready:" in msg
|
||||
assert "Remote agent preview" in msg
|
||||
|
||||
|
||||
def test_preview_remote_agent_payload_rejects_invalid_schema(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
settings = SessionsSettings(ssh_config_path=tmp_path / "config")
|
||||
monkeypatch.setattr(commands, "SessionsSettings", lambda: settings)
|
||||
monkeypatch.setattr(commands.sublime, "cache_path", lambda: str(tmp_path / "cache"))
|
||||
status_messages: List[str] = []
|
||||
monkeypatch.setattr(commands.sublime, "status_message", status_messages.append)
|
||||
recent_store = commands._recent_store(settings)
|
||||
recent_store.save_index(
|
||||
RecentWorkspaceIndex(
|
||||
(
|
||||
RecentWorkspace(
|
||||
"prod",
|
||||
"/srv/ws",
|
||||
"cache-123",
|
||||
"2026-04-12T03:00:00+00:00",
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
window = FakeWindow(project_data={"settings": {PROJECT_SETTINGS_KEY: "cache-123"}})
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"run_ssh_remote_command",
|
||||
lambda host_alias, remote_argv, timeout_s=30.0: SshRunResult(
|
||||
0,
|
||||
json.dumps({"kind": "sessions.agent_editor_preview", "schema_version": 2}),
|
||||
"",
|
||||
("ssh", host_alias, remote_argv[-1]),
|
||||
),
|
||||
)
|
||||
|
||||
commands.SessionsPreviewRemoteAgentPayloadCommand(window).run()
|
||||
assert window.input_panels[-1][0] == "Remote agent payload command:"
|
||||
window.input_callbacks[-1]("cat /tmp/payload.json")
|
||||
|
||||
assert status_messages[-1].startswith("Sessions warning: Schema validation failed")
|
||||
assert (
|
||||
"Schema validation failed"
|
||||
in window.output_panels["sessions_remote_agent_payload"].content
|
||||
)
|
||||
|
||||
|
||||
def test_preview_remote_agent_payload_surfaces_transport_failure(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
settings = SessionsSettings(ssh_config_path=tmp_path / "config")
|
||||
monkeypatch.setattr(commands, "SessionsSettings", lambda: settings)
|
||||
monkeypatch.setattr(commands.sublime, "cache_path", lambda: str(tmp_path / "cache"))
|
||||
status_messages: List[str] = []
|
||||
monkeypatch.setattr(commands.sublime, "status_message", status_messages.append)
|
||||
recent_store = commands._recent_store(settings)
|
||||
recent_store.save_index(
|
||||
RecentWorkspaceIndex(
|
||||
(
|
||||
RecentWorkspace(
|
||||
"prod",
|
||||
"/srv/ws",
|
||||
"cache-123",
|
||||
"2026-04-12T03:00:00+00:00",
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
window = FakeWindow(project_data={"settings": {PROJECT_SETTINGS_KEY: "cache-123"}})
|
||||
monkeypatch.setattr(
|
||||
commands,
|
||||
"run_ssh_remote_command",
|
||||
lambda host_alias, remote_argv, timeout_s=30.0: SshRunResult(
|
||||
255,
|
||||
"",
|
||||
"Permission denied (publickey).",
|
||||
("ssh", host_alias, remote_argv[-1]),
|
||||
),
|
||||
)
|
||||
|
||||
commands.SessionsPreviewRemoteAgentPayloadCommand(window).run()
|
||||
assert window.input_panels[-1][0] == "Remote agent payload command:"
|
||||
window.input_callbacks[-1]("cat /tmp/payload.json")
|
||||
|
||||
msg = status_messages[-1]
|
||||
assert "Sessions warning:" in msg
|
||||
assert "publickey" in msg
|
||||
|
||||
|
||||
def test_present_merged_remote_python_pipeline_panel_and_status(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
@@ -1322,44 +1182,3 @@ def test_probe_remote_extension_installed_pyright_fallbacks_to_cli(
|
||||
assert commands._probe_remote_extension_installed(context, spec) is True
|
||||
assert _remote_extension_sh_c_contains(calls[0], "pyright-langserver")
|
||||
assert _remote_extension_sh_c_contains(calls[1], "pyright --version")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SessionsPreviewRemoteAgentPayloadCommand.is_visible — palette gating.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _install_fake_load_settings(monkeypatch, value: object) -> None:
|
||||
"""Patch ``sublime.load_settings`` so the command sees a sentinel value."""
|
||||
|
||||
class _StoredSettings:
|
||||
def get(self, key: str, default=None):
|
||||
assert key == "sessions_show_dev_commands"
|
||||
return value
|
||||
|
||||
monkeypatch.setattr(
|
||||
commands.sublime,
|
||||
"load_settings",
|
||||
lambda _name: _StoredSettings(),
|
||||
raising=False,
|
||||
)
|
||||
|
||||
|
||||
def test_preview_remote_agent_payload_hidden_by_default(monkeypatch) -> None:
|
||||
_install_fake_load_settings(monkeypatch, False)
|
||||
cmd = commands.SessionsPreviewRemoteAgentPayloadCommand(FakeWindow())
|
||||
assert cmd.is_visible() is False
|
||||
|
||||
|
||||
def test_preview_remote_agent_payload_shown_when_dev_flag_on(monkeypatch) -> None:
|
||||
_install_fake_load_settings(monkeypatch, True)
|
||||
cmd = commands.SessionsPreviewRemoteAgentPayloadCommand(FakeWindow())
|
||||
assert cmd.is_visible() is True
|
||||
|
||||
|
||||
def test_preview_remote_agent_payload_hidden_when_load_settings_unavailable(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(commands.sublime, "load_settings", None, raising=False)
|
||||
cmd = commands.SessionsPreviewRemoteAgentPayloadCommand(FakeWindow())
|
||||
assert cmd.is_visible() is False
|
||||
|
||||
@@ -26,20 +26,13 @@ def test_command_palette_prioritizes_recent_workspace_entry() -> None:
|
||||
assert "sessions_remote_tree_refresh" in [item["command"] for item in payload]
|
||||
assert "sessions_open_remote_file" in [item["command"] for item in payload]
|
||||
assert "sessions_open_remote_terminal" in [item["command"] for item in payload]
|
||||
assert "sessions_preview_remote_agent_payload" in [
|
||||
item["command"] for item in payload
|
||||
]
|
||||
assert "sessions_install_remote_extension" in [item["command"] for item in payload]
|
||||
assert "sessions_remove_remote_extension" in [item["command"] for item in payload]
|
||||
assert "sessions_remote_extension_status" in [item["command"] for item in payload]
|
||||
assert "sessions_open_remote_jupyter" in [item["command"] for item in payload]
|
||||
assert "sessions_stop_remote_jupyter" in [item["command"] for item in payload]
|
||||
assert "sessions_open_remote_marimo" in [item["command"] for item in payload]
|
||||
assert "sessions_stop_remote_marimo" in [item["command"] for item in payload]
|
||||
assert "sessions_diagnose_lsp_workspace" in [item["command"] for item in payload]
|
||||
assert "sessions_select_python_interpreter" in [item["command"] for item in payload]
|
||||
assert "sessions_clear_python_interpreter" in [item["command"] for item in payload]
|
||||
assert "sessions_setup_remote_debugging" in [item["command"] for item in payload]
|
||||
assert "sessions_register_jupyter_kernel" in [item["command"] for item in payload]
|
||||
assert "sessions_expand_deferred_directory" in [item["command"] for item in payload]
|
||||
assert "sessions_new_agent_session" in [item["command"] for item in payload]
|
||||
assert "sessions_show_agent_switcher" in [item["command"] for item in payload]
|
||||
assert "sessions_kill_agent_session" in [item["command"] for item in payload]
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
@@ -25,19 +24,6 @@ def test_mark_input_panel_as_password_sets_password_setting() -> None:
|
||||
assert panel.settings().values["password"] is True
|
||||
|
||||
|
||||
def test_parse_agent_payload_from_multiline_json_stdout() -> None:
|
||||
payload = {
|
||||
"kind": "sessions.agent_editor_preview",
|
||||
"schema_version": 1,
|
||||
"title": "T",
|
||||
"unified_diff": "--- a/x\n+++ b/x\n",
|
||||
}
|
||||
stdout = json.dumps(payload, indent=2)
|
||||
parsed = commands._parse_agent_payload_from_stdout(stdout)
|
||||
assert parsed is not None
|
||||
assert parsed.title == "T"
|
||||
|
||||
|
||||
def test_sessions_settings_base_file_resource_uses_packages_scheme() -> None:
|
||||
"""``edit_settings`` needs a ``Packages/…`` path for the default/user split."""
|
||||
resource = commands._sessions_settings_base_file_resource()
|
||||
@@ -240,11 +226,6 @@ def test_mirror_hydrate_no_sublime(monkeypatch) -> None:
|
||||
assert commands._mirror_hydrate_placeholders_on_open() is True
|
||||
|
||||
|
||||
def test_remote_terminal_no_sublime(monkeypatch) -> None:
|
||||
monkeypatch.delattr(commands.sublime, "load_settings", raising=False)
|
||||
assert commands._remote_terminal_shell_command() == "bash -il"
|
||||
|
||||
|
||||
def test_mirror_fast_sidebar_no_sublime(monkeypatch) -> None:
|
||||
monkeypatch.delattr(commands.sublime, "load_settings", raising=False)
|
||||
assert commands._mirror_fast_sidebar_first_sync() is True
|
||||
@@ -294,21 +275,6 @@ def test_mirror_hydrate_with_settings(sublime_settings) -> None:
|
||||
assert commands._mirror_hydrate_placeholders_on_open() is False
|
||||
|
||||
|
||||
def test_remote_terminal_with_settings(sublime_settings) -> None:
|
||||
sublime_settings({"sessions_remote_terminal_shell": "zsh -l"})
|
||||
assert commands._remote_terminal_shell_command() == "zsh -l"
|
||||
|
||||
|
||||
def test_remote_terminal_empty_shell(sublime_settings) -> None:
|
||||
sublime_settings({"sessions_remote_terminal_shell": " "})
|
||||
assert commands._remote_terminal_shell_command() == "bash -il"
|
||||
|
||||
|
||||
def test_remote_terminal_newline_shell(sublime_settings) -> None:
|
||||
sublime_settings({"sessions_remote_terminal_shell": "bash\n-l"})
|
||||
assert commands._remote_terminal_shell_command() == "bash -il"
|
||||
|
||||
|
||||
def test_mirror_fast_sidebar_with_settings(sublime_settings) -> None:
|
||||
sublime_settings({"sessions_mirror_fast_sidebar_first_sync": False})
|
||||
assert commands._mirror_fast_sidebar_first_sync() is False
|
||||
@@ -399,29 +365,6 @@ def test_effective_sessions_settings_for_remote_python(
|
||||
assert isinstance(settings, SessionsSettings)
|
||||
|
||||
|
||||
def test_parse_agent_payload_from_stdout_valid(monkeypatch) -> None:
|
||||
import json
|
||||
|
||||
from sessions.agent_remote_payload import (
|
||||
AGENT_EDITOR_PREVIEW_KIND,
|
||||
SUPPORTED_SCHEMA_VERSION,
|
||||
)
|
||||
|
||||
body = {
|
||||
"kind": AGENT_EDITOR_PREVIEW_KIND,
|
||||
"schema_version": SUPPORTED_SCHEMA_VERSION,
|
||||
"title": "test",
|
||||
"unified_diff": "diff",
|
||||
}
|
||||
payload = commands._parse_agent_payload_from_stdout(json.dumps(body))
|
||||
assert payload is not None
|
||||
assert payload.title == "test"
|
||||
|
||||
|
||||
def test_parse_agent_payload_from_stdout_invalid() -> None:
|
||||
assert commands._parse_agent_payload_from_stdout("not json") is None
|
||||
|
||||
|
||||
def test_interactive_ssh_lane_basic() -> None:
|
||||
commands._begin_interactive_ssh_lane("test-host-lane")
|
||||
commands._end_interactive_ssh_lane("test-host-lane")
|
||||
@@ -434,21 +377,6 @@ def test_interactive_ssh_lane_double_begin() -> None:
|
||||
commands._end_interactive_ssh_lane("test-host-double")
|
||||
|
||||
|
||||
def test_present_remote_agent_payload(monkeypatch) -> None:
|
||||
from sessions.agent_remote_payload import AgentEditorPayload
|
||||
|
||||
monkeypatch.setattr(commands.sublime, "status_message", lambda m: None)
|
||||
payload = AgentEditorPayload(
|
||||
kind="sessions.agent_editor_preview",
|
||||
schema_version=1,
|
||||
title="Test",
|
||||
unified_diff="--- a\n+++ b",
|
||||
target_remote_path="/remote/file.py",
|
||||
)
|
||||
window = FakeWindow(project_data={})
|
||||
commands._present_remote_agent_payload(window, payload)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"pending, dropped, expected",
|
||||
[
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Unit tests for remote Jupyter Lab hosting primitives."""
|
||||
"""Unit tests for remote marimo edit-server hosting primitives."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -9,12 +9,13 @@ from types import SimpleNamespace
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
import pytest
|
||||
from sessions.jupyter_hosting import (
|
||||
JupyterHostingError,
|
||||
JupyterServerInfo,
|
||||
JupyterSessionManager,
|
||||
from sessions.marimo_hosting import (
|
||||
MarimoHostingError,
|
||||
MarimoServerInfo,
|
||||
MarimoSessionManager,
|
||||
_parse_remote_port_from_log,
|
||||
build_notebook_url,
|
||||
marimo_url_for_notebook,
|
||||
)
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
@@ -27,11 +28,11 @@ def _fake_server(
|
||||
workspace_root: str = "/home/user/proj",
|
||||
local_port: int = 9000,
|
||||
token: str = "tok123",
|
||||
) -> JupyterServerInfo:
|
||||
return JupyterServerInfo(
|
||||
) -> MarimoServerInfo:
|
||||
return MarimoServerInfo(
|
||||
host_alias="dev",
|
||||
workspace_root=workspace_root,
|
||||
remote_port=8888,
|
||||
remote_port=2718,
|
||||
local_port=local_port,
|
||||
token=token,
|
||||
pid=1234,
|
||||
@@ -40,41 +41,45 @@ def _fake_server(
|
||||
)
|
||||
|
||||
|
||||
def test_build_notebook_url_with_path_inside_workspace_returns_tree_url() -> None:
|
||||
server = _fake_server(
|
||||
workspace_root="/home/user/proj", local_port=9000, token="tok123"
|
||||
def test_build_notebook_url_with_remote_path_returns_file_query_url() -> None:
|
||||
server = _fake_server(local_port=9000, token="tok123")
|
||||
url = build_notebook_url(server, "/home/user/proj/nb/a.py")
|
||||
assert url == (
|
||||
"http://127.0.0.1:9000/?file=/home/user/proj/nb/a.py&access_token=tok123"
|
||||
)
|
||||
url = build_notebook_url(server, "/home/user/proj/nb/a.ipynb")
|
||||
assert url == "http://127.0.0.1:9000/lab/tree/nb/a.ipynb?token=tok123"
|
||||
|
||||
|
||||
def test_build_notebook_url_with_path_outside_workspace_returns_lab_only(
|
||||
def test_build_notebook_url_with_none_path_returns_root_url() -> None:
|
||||
server = _fake_server(local_port=9000, token="tok123")
|
||||
assert build_notebook_url(server, None) == (
|
||||
"http://127.0.0.1:9000/?access_token=tok123"
|
||||
)
|
||||
|
||||
|
||||
def test_build_notebook_url_percent_encodes_spaces_in_path() -> None:
|
||||
server = _fake_server(local_port=9000, token="tok123")
|
||||
url = build_notebook_url(server, "/srv/proj/sub dir/a b.py")
|
||||
# The path is first percent-encoded by ``quote`` (space → %20) then the
|
||||
# whole ``file=`` query value is percent-encoded again by ``urlencode``,
|
||||
# so the literal ``%`` in ``%20`` becomes ``%25`` → final ``%2520``.
|
||||
# Slashes stay literal because the helper passes ``safe="/"``.
|
||||
assert url == (
|
||||
"http://127.0.0.1:9000/?file=/srv/proj/sub%2520dir/a%2520b.py"
|
||||
"&access_token=tok123"
|
||||
)
|
||||
|
||||
|
||||
def test_build_notebook_url_logs_at_info_level(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
server = _fake_server(workspace_root="/home/user/proj")
|
||||
with caplog.at_level("INFO", logger="sessions.jupyter_hosting"):
|
||||
url = build_notebook_url(server, "/etc/hosts")
|
||||
assert url == "http://127.0.0.1:9000/lab?token=tok123"
|
||||
assert any("not inside workspace_root" in rec.message for rec in caplog.records)
|
||||
server = _fake_server(local_port=9000, token="tok123")
|
||||
with caplog.at_level("INFO", logger="sessions.marimo_hosting"):
|
||||
build_notebook_url(server, "/srv/proj/nb.py")
|
||||
assert any("build_notebook_url" in rec.message for rec in caplog.records)
|
||||
|
||||
|
||||
def test_build_notebook_url_with_none_path_returns_lab_only() -> None:
|
||||
server = _fake_server()
|
||||
assert build_notebook_url(server, None) == "http://127.0.0.1:9000/lab?token=tok123"
|
||||
|
||||
|
||||
def test_build_notebook_url_percent_encodes_relative_path_segments() -> None:
|
||||
server = _fake_server(workspace_root="/srv/proj")
|
||||
url = build_notebook_url(server, "/srv/proj/sub dir/a b.ipynb")
|
||||
# Space becomes %20; slashes inside the relative path stay literal.
|
||||
assert url == ("http://127.0.0.1:9000/lab/tree/sub%20dir/a%20b.ipynb?token=tok123")
|
||||
|
||||
|
||||
def test_build_notebook_url_path_equal_to_workspace_returns_lab_only() -> None:
|
||||
server = _fake_server(workspace_root="/srv/proj")
|
||||
assert build_notebook_url(server, "/srv/proj") == (
|
||||
"http://127.0.0.1:9000/lab?token=tok123"
|
||||
)
|
||||
def test_marimo_url_for_notebook_is_alias_of_build_notebook_url() -> None:
|
||||
assert marimo_url_for_notebook is build_notebook_url
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
@@ -84,23 +89,33 @@ def test_build_notebook_url_path_equal_to_workspace_returns_lab_only() -> None:
|
||||
|
||||
def test_parse_remote_port_extracts_first_bound_url() -> None:
|
||||
log = (
|
||||
"[I 2026-04-23 09:00:00.000 ServerApp] Jupyter Server starting\n"
|
||||
"[I ServerApp] http://127.0.0.1:8891/lab?token=abcd\n"
|
||||
"[I ServerApp] http://127.0.0.1:9999/lab?token=abcd\n"
|
||||
"Starting marimo edit server...\n"
|
||||
"Edit a notebook in your browser:\n"
|
||||
"http://127.0.0.1:2718/?access_token=abcd\n"
|
||||
"http://127.0.0.1:9999/?access_token=abcd\n"
|
||||
)
|
||||
assert _parse_remote_port_from_log(log) == 8891
|
||||
assert _parse_remote_port_from_log(log) == 2718
|
||||
|
||||
|
||||
def test_parse_remote_port_handles_url_without_path_or_query() -> None:
|
||||
log = "running at http://127.0.0.1:5005\n"
|
||||
assert _parse_remote_port_from_log(log) == 5005
|
||||
|
||||
|
||||
def test_parse_remote_port_returns_none_when_no_url_yet() -> None:
|
||||
assert _parse_remote_port_from_log("starting up...\n") is None
|
||||
|
||||
|
||||
def test_parse_remote_port_returns_none_for_empty_log() -> None:
|
||||
assert _parse_remote_port_from_log("") is None
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# JupyterServerInfo dataclass invariants
|
||||
# MarimoServerInfo dataclass invariants
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_jupyter_server_info_is_frozen_and_hashable() -> None:
|
||||
def test_marimo_server_info_is_frozen_and_hashable() -> None:
|
||||
info = _fake_server()
|
||||
with pytest.raises(FrozenInstanceError):
|
||||
info.local_port = 1 # type: ignore[misc]
|
||||
@@ -151,7 +166,7 @@ def _build_manager(
|
||||
local_port: int = 54321,
|
||||
clock_values: Any = None,
|
||||
connect_ok: bool = True,
|
||||
) -> JupyterSessionManager:
|
||||
) -> MarimoSessionManager:
|
||||
clock_iter = iter(clock_values) if clock_values is not None else None
|
||||
|
||||
def clock() -> float:
|
||||
@@ -171,7 +186,7 @@ def _build_manager(
|
||||
if not connect_ok:
|
||||
raise OSError(f"refused {port}")
|
||||
|
||||
manager = JupyterSessionManager(
|
||||
manager = MarimoSessionManager(
|
||||
ssh_command_builder=lambda alias: ["ssh", "-F", "/fake/config", alias],
|
||||
popen=popen,
|
||||
run=run,
|
||||
@@ -197,7 +212,7 @@ def test_ensure_started_builds_correct_ssh_argv_and_returns_info() -> None:
|
||||
run = _RunRecorder(
|
||||
responses=[
|
||||
(0, "4242\n", ""),
|
||||
(0, "http://127.0.0.1:8891/lab?token=tok-1\n", ""),
|
||||
(0, "http://127.0.0.1:2718/?access_token=tok-1\n", ""),
|
||||
]
|
||||
)
|
||||
alive: set = {7777}
|
||||
@@ -213,19 +228,16 @@ def test_ensure_started_builds_correct_ssh_argv_and_returns_info() -> None:
|
||||
|
||||
assert info.host_alias == "dev"
|
||||
assert info.workspace_root == "/srv/proj"
|
||||
assert info.remote_port == 8891
|
||||
assert info.remote_port == 2718
|
||||
assert info.local_port == 54321
|
||||
assert info.token == "tok-1"
|
||||
assert info.pid == 4242
|
||||
assert info.tunnel_pid == 7777
|
||||
|
||||
# First ssh call: remote jupyter spawn via bash -lc ...
|
||||
# The whole ``bash -lc <script>`` is bundled into ONE SSH-side
|
||||
# argument so the remote login shell doesn't tokenise the script
|
||||
# and pass only the leading word ("mkdir") to bash. v0.6.12 test
|
||||
# pass repro: the unbundled form left the redirect unevaluated and
|
||||
# the jupyter log file was never created → 60s timeout with
|
||||
# ``cat: ... No such file or directory``.
|
||||
# First ssh call: remote marimo spawn via bash -lc <single-arg script>.
|
||||
# The whole ``bash -lc <script>`` is bundled into ONE SSH-side argument
|
||||
# so the remote login shell doesn't tokenise the script and pass only
|
||||
# the leading word ("mkdir") to bash.
|
||||
spawn_argv = run.calls[0]
|
||||
assert spawn_argv[:3] == ["ssh", "-F", "/fake/config"]
|
||||
assert spawn_argv[3] == "dev"
|
||||
@@ -235,15 +247,16 @@ def test_ensure_started_builds_correct_ssh_argv_and_returns_info() -> None:
|
||||
)
|
||||
remote_command = spawn_argv[4]
|
||||
assert remote_command.startswith("bash -lc "), remote_command
|
||||
# The script body lives inside shlex-quoted single quotes so the
|
||||
# remote shell sees ``bash -lc 'mkdir -p ~/.sessions && ...'``.
|
||||
assert "'" in remote_command
|
||||
assert "nohup jupyter lab --no-browser" in remote_command
|
||||
assert "--ServerApp.ip=127.0.0.1" in remote_command
|
||||
assert "--ServerApp.port=0" in remote_command
|
||||
assert "--ServerApp.token=tok-1" in remote_command
|
||||
assert "--ServerApp.root_dir=/srv/proj" in remote_command
|
||||
assert "~/.sessions/jupyter-tok-1.log" in remote_command
|
||||
# Script body must include the marimo edit launch with our flags.
|
||||
assert "nohup marimo edit --headless" in remote_command
|
||||
assert "--host 127.0.0.1" in remote_command
|
||||
assert '--port "$PORT"' in remote_command
|
||||
assert "--token-password tok-1" in remote_command
|
||||
assert "/srv/proj" in remote_command
|
||||
assert "~/.sessions/marimo-tok-1.log" in remote_command
|
||||
# Ephemeral port is picked remotely with python3 socket bind.
|
||||
assert "python3 -c" in remote_command
|
||||
assert "socket" in remote_command
|
||||
trimmed = remote_command.rstrip()
|
||||
assert trimmed.endswith("echo $!'") or trimmed.endswith('echo $!"')
|
||||
|
||||
@@ -255,7 +268,7 @@ def test_ensure_started_builds_correct_ssh_argv_and_returns_info() -> None:
|
||||
"/fake/config",
|
||||
"dev",
|
||||
"cat",
|
||||
"~/.sessions/jupyter-tok-1.log",
|
||||
"~/.sessions/marimo-tok-1.log",
|
||||
]
|
||||
|
||||
# Local tunnel Popen argv.
|
||||
@@ -264,7 +277,7 @@ def test_ensure_started_builds_correct_ssh_argv_and_returns_info() -> None:
|
||||
"ssh",
|
||||
"-N",
|
||||
"-L",
|
||||
"127.0.0.1:54321:127.0.0.1:8891",
|
||||
"127.0.0.1:54321:127.0.0.1:2718",
|
||||
"dev",
|
||||
]
|
||||
]
|
||||
@@ -275,7 +288,7 @@ def test_ensure_started_is_idempotent_when_tunnel_still_alive() -> None:
|
||||
run = _RunRecorder(
|
||||
responses=[
|
||||
(0, "4242\n", ""),
|
||||
(0, "http://127.0.0.1:8891/lab?token=t\n", ""),
|
||||
(0, "http://127.0.0.1:2718/?access_token=t\n", ""),
|
||||
]
|
||||
)
|
||||
alive: set = {7777}
|
||||
@@ -298,11 +311,10 @@ def test_ensure_started_respawns_when_previous_tunnel_is_dead() -> None:
|
||||
responses=[
|
||||
# First launch:
|
||||
(0, "100\n", ""),
|
||||
(0, "http://127.0.0.1:8900/lab?token=a\n", ""),
|
||||
# Teardown of stale (we will not drive that path here — ensure_started
|
||||
# just drops the entry). Second launch:
|
||||
(0, "http://127.0.0.1:2718/?access_token=a\n", ""),
|
||||
# Second launch (after stale entry dropped):
|
||||
(0, "200\n", ""),
|
||||
(0, "http://127.0.0.1:8901/lab?token=b\n", ""),
|
||||
(0, "http://127.0.0.1:2719/?access_token=b\n", ""),
|
||||
]
|
||||
)
|
||||
alive: set = {9999}
|
||||
@@ -322,7 +334,7 @@ def test_ensure_started_respawns_when_previous_tunnel_is_dead() -> None:
|
||||
|
||||
assert first is not second
|
||||
assert second.token == "b"
|
||||
assert second.remote_port == 8901
|
||||
assert second.remote_port == 2719
|
||||
assert len(popen.calls) == 2
|
||||
|
||||
|
||||
@@ -331,7 +343,7 @@ def test_ensure_started_raises_when_local_probe_fails_and_tears_down() -> None:
|
||||
run = _RunRecorder(
|
||||
responses=[
|
||||
(0, "4242\n", ""),
|
||||
(0, "http://127.0.0.1:8891/lab?token=t\n", ""),
|
||||
(0, "http://127.0.0.1:2718/?access_token=t\n", ""),
|
||||
]
|
||||
)
|
||||
alive: set = {7777}
|
||||
@@ -344,11 +356,11 @@ def test_ensure_started_raises_when_local_probe_fails_and_tears_down() -> None:
|
||||
connect_ok=False,
|
||||
)
|
||||
|
||||
# Capture os.kill to avoid touching real processes during teardown.
|
||||
# Capture local kill so we don't touch real processes during teardown.
|
||||
kill_calls: List[Tuple[int, int]] = []
|
||||
manager._kill_local_tunnel = lambda pid: kill_calls.append((pid, signal.SIGTERM)) # type: ignore[assignment]
|
||||
|
||||
with pytest.raises(JupyterHostingError, match="local tunnel probe"):
|
||||
with pytest.raises(MarimoHostingError, match="local tunnel probe"):
|
||||
manager.ensure_started("dev", "/srv/proj")
|
||||
|
||||
# Teardown issued remote kill + log rm via self._run.
|
||||
@@ -365,27 +377,69 @@ def test_ensure_started_raises_when_remote_pid_output_is_bogus() -> None:
|
||||
manager = _build_manager(
|
||||
popen=popen, run=run, alive_pids=set(), tokens=["t"], local_port=1234
|
||||
)
|
||||
with pytest.raises(JupyterHostingError, match="non-numeric"):
|
||||
with pytest.raises(MarimoHostingError, match="non-numeric"):
|
||||
manager.ensure_started("dev", "/srv/proj")
|
||||
|
||||
|
||||
def test_ensure_started_raises_when_log_never_yields_url(monkeypatch) -> None:
|
||||
def test_ensure_started_raises_when_remote_spawn_returns_no_pid() -> None:
|
||||
popen = _PopenRecorder(pid=7777)
|
||||
run = _RunRecorder(responses=[(0, "", "")])
|
||||
manager = _build_manager(
|
||||
popen=popen, run=run, alive_pids=set(), tokens=["t"], local_port=1234
|
||||
)
|
||||
with pytest.raises(MarimoHostingError, match="no PID output"):
|
||||
manager.ensure_started("dev", "/srv/proj")
|
||||
|
||||
|
||||
def test_ensure_started_raises_when_remote_spawn_exits_nonzero() -> None:
|
||||
popen = _PopenRecorder(pid=7777)
|
||||
run = _RunRecorder(responses=[(2, "", "ssh: connect failed")])
|
||||
manager = _build_manager(
|
||||
popen=popen, run=run, alive_pids=set(), tokens=["t"], local_port=1234
|
||||
)
|
||||
with pytest.raises(MarimoHostingError, match="exited 2"):
|
||||
manager.ensure_started("dev", "/srv/proj")
|
||||
# No local tunnel was attempted since spawn failed.
|
||||
assert popen.calls == []
|
||||
|
||||
|
||||
def test_ensure_started_raises_when_log_never_yields_url() -> None:
|
||||
popen = _PopenRecorder(pid=7777)
|
||||
# After the initial PID response, every subsequent cat returns empty.
|
||||
run = _RunRecorder(responses=[(0, "100\n", "")])
|
||||
|
||||
# clock: first call returns 0 (inside await loop entry), then jumps past
|
||||
# the 15s deadline so the loop gives up immediately on next check.
|
||||
# clock: start at 0, then jump past the 60s deadline so the loop bails.
|
||||
manager = _build_manager(
|
||||
popen=popen,
|
||||
run=run,
|
||||
alive_pids=set(),
|
||||
tokens=["t"],
|
||||
local_port=1234,
|
||||
clock_values=[0.0, 0.0, 100.0, 100.0, 100.0],
|
||||
clock_values=[0.0, 0.0, 1000.0, 1000.0, 1000.0],
|
||||
)
|
||||
|
||||
with pytest.raises(JupyterHostingError, match="timed out"):
|
||||
with pytest.raises(MarimoHostingError, match="timed out"):
|
||||
manager.ensure_started("dev", "/srv/proj")
|
||||
|
||||
|
||||
def test_ensure_started_timeout_includes_stderr_when_log_missing() -> None:
|
||||
popen = _PopenRecorder(pid=7777)
|
||||
# cat returns non-zero with stderr — log file doesn't exist yet.
|
||||
run = _RunRecorder(
|
||||
responses=[
|
||||
(0, "100\n", ""),
|
||||
(1, "", "cat: ~/.sessions/marimo-t.log: No such file or directory"),
|
||||
]
|
||||
)
|
||||
manager = _build_manager(
|
||||
popen=popen,
|
||||
run=run,
|
||||
alive_pids=set(),
|
||||
tokens=["t"],
|
||||
local_port=1234,
|
||||
clock_values=[0.0, 0.0, 1000.0, 1000.0, 1000.0],
|
||||
)
|
||||
with pytest.raises(MarimoHostingError, match="No such file or directory"):
|
||||
manager.ensure_started("dev", "/srv/proj")
|
||||
|
||||
|
||||
@@ -399,7 +453,7 @@ def test_stop_kills_both_local_and_remote_pids() -> None:
|
||||
run = _RunRecorder(
|
||||
responses=[
|
||||
(0, "4242\n", ""),
|
||||
(0, "http://127.0.0.1:8891/lab?token=t\n", ""),
|
||||
(0, "http://127.0.0.1:2718/?access_token=t\n", ""),
|
||||
]
|
||||
)
|
||||
alive: set = {7777}
|
||||
@@ -414,15 +468,15 @@ def test_stop_kills_both_local_and_remote_pids() -> None:
|
||||
if sig == signal.SIGTERM:
|
||||
alive.discard(pid)
|
||||
|
||||
import sessions.jupyter_hosting as jh
|
||||
import sessions.marimo_hosting as mh
|
||||
|
||||
original_os_kill = jh.os.kill
|
||||
jh.os.kill = fake_os_kill # type: ignore[assignment]
|
||||
original_os_kill = mh.os.kill
|
||||
mh.os.kill = fake_os_kill # type: ignore[assignment]
|
||||
try:
|
||||
info = manager.ensure_started("dev", "/srv/proj")
|
||||
manager.stop("dev")
|
||||
finally:
|
||||
jh.os.kill = original_os_kill # type: ignore[assignment]
|
||||
mh.os.kill = original_os_kill # type: ignore[assignment]
|
||||
|
||||
# Local tunnel SIGTERM issued.
|
||||
assert (info.tunnel_pid, signal.SIGTERM) in kill_log
|
||||
@@ -457,10 +511,10 @@ def test_stop_all_tears_down_every_registered_server() -> None:
|
||||
responses=[
|
||||
# host A launch:
|
||||
(0, "1\n", ""),
|
||||
(0, "http://127.0.0.1:8001/lab?token=a\n", ""),
|
||||
(0, "http://127.0.0.1:8001/?access_token=a\n", ""),
|
||||
# host B launch:
|
||||
(0, "2\n", ""),
|
||||
(0, "http://127.0.0.1:8002/lab?token=b\n", ""),
|
||||
(0, "http://127.0.0.1:8002/?access_token=b\n", ""),
|
||||
]
|
||||
)
|
||||
alive: set = {111}
|
||||
@@ -472,10 +526,10 @@ def test_stop_all_tears_down_every_registered_server() -> None:
|
||||
local_port=1234,
|
||||
)
|
||||
|
||||
import sessions.jupyter_hosting as jh
|
||||
import sessions.marimo_hosting as mh
|
||||
|
||||
original = jh.os.kill
|
||||
jh.os.kill = lambda pid, sig: alive.discard(pid) # type: ignore[assignment]
|
||||
original = mh.os.kill
|
||||
mh.os.kill = lambda pid, sig: alive.discard(pid) # type: ignore[assignment]
|
||||
try:
|
||||
manager.ensure_started("host-a", "/a")
|
||||
manager.ensure_started("host-b", "/b")
|
||||
@@ -483,7 +537,7 @@ def test_stop_all_tears_down_every_registered_server() -> None:
|
||||
assert manager.get("host-b") is not None
|
||||
manager.stop_all()
|
||||
finally:
|
||||
jh.os.kill = original # type: ignore[assignment]
|
||||
mh.os.kill = original # type: ignore[assignment]
|
||||
|
||||
assert manager.get("host-a") is None
|
||||
assert manager.get("host-b") is None
|
||||
@@ -502,8 +556,8 @@ def test_concurrent_ensure_started_coalesces_to_single_launch() -> None:
|
||||
|
||||
def run(argv, **kwargs): # type: ignore[no-untyped-def]
|
||||
run_calls.append(list(argv))
|
||||
# Spawn call contains "nohup jupyter lab" inside the bash -lc script.
|
||||
if any("nohup jupyter lab" in arg for arg in argv):
|
||||
# Spawn call contains "nohup marimo edit" inside the bash -lc script.
|
||||
if any("nohup marimo edit" in arg for arg in argv):
|
||||
launch_counter["n"] += 1
|
||||
# Block the first launch so the second thread has to wait on
|
||||
# the registry lock.
|
||||
@@ -512,7 +566,7 @@ def test_concurrent_ensure_started_coalesces_to_single_launch() -> None:
|
||||
if "cat" in argv:
|
||||
return SimpleNamespace(
|
||||
returncode=0,
|
||||
stdout="http://127.0.0.1:8891/lab?token=tok\n",
|
||||
stdout="http://127.0.0.1:2718/?access_token=tok\n",
|
||||
stderr="",
|
||||
)
|
||||
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
||||
@@ -522,7 +576,7 @@ def test_concurrent_ensure_started_coalesces_to_single_launch() -> None:
|
||||
return SimpleNamespace(pid=7777)
|
||||
|
||||
alive: set = {7777}
|
||||
manager = JupyterSessionManager(
|
||||
manager = MarimoSessionManager(
|
||||
ssh_command_builder=lambda alias: ["ssh", alias],
|
||||
popen=popen,
|
||||
run=run,
|
||||
@@ -534,7 +588,7 @@ def test_concurrent_ensure_started_coalesces_to_single_launch() -> None:
|
||||
)
|
||||
manager._tunnel_is_alive = lambda pid: pid in alive # type: ignore[assignment]
|
||||
|
||||
results: Dict[int, JupyterServerInfo] = {}
|
||||
results: Dict[int, MarimoServerInfo] = {}
|
||||
errors: Dict[int, BaseException] = {}
|
||||
|
||||
def worker(idx: int) -> None:
|
||||
@@ -571,271 +625,45 @@ def test_default_manager_uses_subprocess_defaults() -> None:
|
||||
# subprocess with the Windows-console suppression kwargs, so they're
|
||||
# not the raw subprocess.run / subprocess.Popen callables any more;
|
||||
# verify they're callable and not None instead.
|
||||
manager = JupyterSessionManager()
|
||||
manager = MarimoSessionManager()
|
||||
assert manager.get("anywhere") is None
|
||||
assert callable(manager._popen)
|
||||
assert callable(manager._run)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Phase B: kernel_python / workspace_cache_key wiring
|
||||
# Startup-timeout env override
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def _kernel_install_argv_matches(argv: List[str], kernel_python: str) -> bool:
|
||||
"""True iff ``argv`` is ``<ssh prefix> <quoted: python -m pip install ipykernel>``.
|
||||
def test_startup_timeout_env_override_is_respected(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
import importlib
|
||||
|
||||
The helper passes the remote command as a single shell-quoted string so
|
||||
SSH can't mangle args that contain spaces.
|
||||
"""
|
||||
if not argv:
|
||||
return False
|
||||
remote = argv[-1]
|
||||
return (
|
||||
kernel_python in remote and "-m pip install" in remote and "ipykernel" in remote
|
||||
)
|
||||
import sessions.marimo_hosting as mh
|
||||
|
||||
monkeypatch.setenv("SESSIONS_MARIMO_STARTUP_TIMEOUT_S", "12.5")
|
||||
try:
|
||||
reloaded = importlib.reload(mh)
|
||||
assert reloaded._resolve_startup_timeout_seconds() == 12.5
|
||||
finally:
|
||||
# Restore module-level constant for unrelated tests in this run.
|
||||
monkeypatch.delenv("SESSIONS_MARIMO_STARTUP_TIMEOUT_S", raising=False)
|
||||
importlib.reload(mh)
|
||||
|
||||
|
||||
def _kernelspec_install_argv_matches(
|
||||
argv: List[str], kernel_python: str, kernel_name: str
|
||||
) -> bool:
|
||||
"""True iff ``argv`` trailing remote-cmd string carries the kernelspec install."""
|
||||
if not argv:
|
||||
return False
|
||||
remote = argv[-1]
|
||||
return (
|
||||
kernel_python in remote
|
||||
and "-m ipykernel install" in remote
|
||||
and "--user" in remote
|
||||
and "--name " + kernel_name in remote
|
||||
)
|
||||
def test_startup_timeout_env_override_falls_back_on_garbage(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
import importlib
|
||||
|
||||
import sessions.marimo_hosting as mh
|
||||
|
||||
def test_ensure_started_without_kernel_python_issues_no_extra_ssh_calls() -> None:
|
||||
popen = _PopenRecorder(pid=7777)
|
||||
run = _RunRecorder(
|
||||
responses=[
|
||||
(0, "4242\n", ""),
|
||||
(0, "http://127.0.0.1:8891/lab?token=t\n", ""),
|
||||
]
|
||||
)
|
||||
alive: set = {7777}
|
||||
manager = _build_manager(
|
||||
popen=popen, run=run, alive_pids=alive, tokens=["t"], local_port=1111
|
||||
)
|
||||
|
||||
info = manager.ensure_started("dev", "/srv/proj")
|
||||
|
||||
# Exactly two ssh run() calls (spawn + log cat) — no pip install, no
|
||||
# kernelspec install when no interpreter was requested.
|
||||
assert len(run.calls) == 2
|
||||
assert info.kernel_name is None
|
||||
spawn_script = run.calls[0][-1]
|
||||
assert "MappingKernelManager" not in spawn_script
|
||||
|
||||
|
||||
def test_ensure_started_with_kernel_python_installs_and_registers_and_passes_flag() -> (
|
||||
None
|
||||
):
|
||||
popen = _PopenRecorder(pid=7777)
|
||||
run = _RunRecorder(
|
||||
responses=[
|
||||
(0, "", ""), # pip install ipykernel
|
||||
(0, "kernelspec installed\n", ""), # ipykernel install
|
||||
(0, "4242\n", ""), # remote jupyter spawn
|
||||
(0, "http://127.0.0.1:8891/lab?token=t\n", ""), # cat log
|
||||
]
|
||||
)
|
||||
alive: set = {7777}
|
||||
manager = _build_manager(
|
||||
popen=popen, run=run, alive_pids=alive, tokens=["t"], local_port=22222
|
||||
)
|
||||
|
||||
info = manager.ensure_started(
|
||||
"dev",
|
||||
"/srv/proj",
|
||||
kernel_python="/home/u/.venv/bin/python",
|
||||
workspace_cache_key="abc123xyz9999",
|
||||
)
|
||||
|
||||
assert info.kernel_name == "sessions-abc123xyz999"
|
||||
|
||||
# First call: pip install ipykernel.
|
||||
assert _kernel_install_argv_matches(run.calls[0], "/home/u/.venv/bin/python"), (
|
||||
run.calls[0]
|
||||
)
|
||||
# Second call: ipykernel install with the derived kernel name.
|
||||
assert _kernelspec_install_argv_matches(
|
||||
run.calls[1], "/home/u/.venv/bin/python", "sessions-abc123xyz999"
|
||||
), run.calls[1]
|
||||
# Third call: remote jupyter spawn whose bash script carries the flag.
|
||||
spawn_script = run.calls[2][-1]
|
||||
assert "MappingKernelManager.default_kernel_name=sessions-abc123xyz999" in (
|
||||
spawn_script
|
||||
), spawn_script
|
||||
|
||||
|
||||
def test_ensure_started_raises_when_ipykernel_install_fails() -> None:
|
||||
popen = _PopenRecorder(pid=7777)
|
||||
run = _RunRecorder(
|
||||
responses=[
|
||||
(1, "", "ERROR: pip broke"),
|
||||
]
|
||||
)
|
||||
manager = _build_manager(
|
||||
popen=popen, run=run, alive_pids=set(), tokens=["t"], local_port=1234
|
||||
)
|
||||
with pytest.raises(JupyterHostingError, match="ipykernel install"):
|
||||
manager.ensure_started(
|
||||
"dev",
|
||||
"/srv/proj",
|
||||
kernel_python="/opt/python",
|
||||
workspace_cache_key="deadbeefcafe01",
|
||||
)
|
||||
# No jupyter spawn attempted after the install failure.
|
||||
assert popen.calls == []
|
||||
|
||||
|
||||
def test_ensure_started_rewrites_tilde_path_to_home_expansion() -> None:
|
||||
# Users who enter ``~/…`` in the interpreter picker must not fail with
|
||||
# ``zsh:1: no such file or directory: ~/…``. The quoter turns leading
|
||||
# ``~/`` into ``"$HOME/…"`` so the remote shell expands $HOME instead of
|
||||
# treating the tilde as a literal path component.
|
||||
popen = _PopenRecorder(pid=0)
|
||||
run = _RunRecorder(responses=[(0, "", ""), (0, "", "")])
|
||||
manager = _build_manager(
|
||||
popen=popen, run=run, alive_pids=set(), tokens=[], local_port=1
|
||||
)
|
||||
manager.register_kernelspec_only(
|
||||
"dev", "~/remote-ssh/sessions/.venv/bin/python", "sessions-xyz"
|
||||
)
|
||||
# The shell-quoted remote command must use $HOME, not the literal tilde.
|
||||
for call in run.calls:
|
||||
remote_cmd = call[-1]
|
||||
assert "~/remote-ssh" not in remote_cmd, remote_cmd
|
||||
assert '"$HOME/remote-ssh/sessions/.venv/bin/python"' in remote_cmd
|
||||
|
||||
|
||||
def test_ensure_ipykernel_installs_pip_via_ensurepip_when_missing() -> None:
|
||||
# uv-created venvs ship without pip, so the first ``pip install`` call
|
||||
# exits 1 with "No module named pip". The manager should bootstrap pip
|
||||
# via ``ensurepip`` and retry the install.
|
||||
popen = _PopenRecorder(pid=0)
|
||||
run = _RunRecorder(
|
||||
responses=[
|
||||
# First pip install: fails with "No module named pip".
|
||||
(1, "", "/home/u/.venv/bin/python: No module named pip"),
|
||||
# ensurepip bootstrap succeeds.
|
||||
(0, "Successfully installed pip", ""),
|
||||
# Retry pip install: succeeds.
|
||||
(0, "", ""),
|
||||
# kernelspec install succeeds.
|
||||
(0, "", ""),
|
||||
]
|
||||
)
|
||||
manager = _build_manager(
|
||||
popen=popen, run=run, alive_pids=set(), tokens=[], local_port=1
|
||||
)
|
||||
manager.register_kernelspec_only("dev", "/home/u/.venv/bin/python", "sessions-xyz")
|
||||
|
||||
# Expect exactly: pip install, ensurepip, pip install retry, kernelspec.
|
||||
# Trailing arg of each call is the shell-quoted remote command string.
|
||||
assert len(run.calls) == 4
|
||||
assert "-m pip install" in run.calls[0][-1]
|
||||
assert "ensurepip" in run.calls[1][-1]
|
||||
assert "-m pip install" in run.calls[2][-1]
|
||||
assert "-m ipykernel install" in run.calls[3][-1]
|
||||
|
||||
|
||||
def test_ensure_ipykernel_raises_when_ensurepip_also_fails() -> None:
|
||||
popen = _PopenRecorder(pid=0)
|
||||
run = _RunRecorder(
|
||||
responses=[
|
||||
(1, "", "No module named pip"),
|
||||
(1, "", "ensurepip is disabled in Debian/Ubuntu"),
|
||||
]
|
||||
)
|
||||
manager = _build_manager(
|
||||
popen=popen, run=run, alive_pids=set(), tokens=[], local_port=1
|
||||
)
|
||||
with pytest.raises(JupyterHostingError, match="ensurepip"):
|
||||
manager.register_kernelspec_only(
|
||||
"dev", "/home/u/.venv/bin/python", "sessions-xyz"
|
||||
)
|
||||
|
||||
|
||||
def test_register_kernelspec_only_treats_already_exists_as_success() -> None:
|
||||
popen = _PopenRecorder(pid=0)
|
||||
run = _RunRecorder(
|
||||
responses=[
|
||||
(0, "", ""), # pip install succeeds
|
||||
(
|
||||
1,
|
||||
"",
|
||||
"KernelSpec sessions-deadbeefcafe already exists at /home/u/.local/...",
|
||||
), # kernelspec install returns non-zero + "already exists"
|
||||
]
|
||||
)
|
||||
manager = _build_manager(
|
||||
popen=popen, run=run, alive_pids=set(), tokens=[], local_port=1
|
||||
)
|
||||
# Must not raise even though the underlying command returned non-zero.
|
||||
manager.register_kernelspec_only("dev", "/opt/python", "sessions-deadbeefcafe")
|
||||
assert len(run.calls) == 2
|
||||
|
||||
|
||||
def test_register_kernelspec_only_raises_on_other_failures() -> None:
|
||||
popen = _PopenRecorder(pid=0)
|
||||
run = _RunRecorder(
|
||||
responses=[
|
||||
(0, "", ""), # pip install succeeds
|
||||
(1, "", "permission denied"), # kernelspec install truly failed
|
||||
]
|
||||
)
|
||||
manager = _build_manager(
|
||||
popen=popen, run=run, alive_pids=set(), tokens=[], local_port=1
|
||||
)
|
||||
with pytest.raises(JupyterHostingError, match="kernelspec install"):
|
||||
manager.register_kernelspec_only("dev", "/opt/python", "sessions-deadbeefcafe")
|
||||
|
||||
|
||||
def test_register_kernelspec_only_rejects_blank_inputs() -> None:
|
||||
manager = _build_manager(
|
||||
popen=_PopenRecorder(),
|
||||
run=_RunRecorder(responses=[]),
|
||||
alive_pids=set(),
|
||||
tokens=[],
|
||||
local_port=1,
|
||||
)
|
||||
with pytest.raises(JupyterHostingError, match="kernel_python"):
|
||||
manager.register_kernelspec_only("dev", "", "sessions-x")
|
||||
with pytest.raises(JupyterHostingError, match="kernel_name"):
|
||||
manager.register_kernelspec_only("dev", "/opt/python", "")
|
||||
|
||||
|
||||
def test_ensure_started_without_cache_key_hashes_workspace_root() -> None:
|
||||
import hashlib as _hashlib
|
||||
|
||||
popen = _PopenRecorder(pid=7777)
|
||||
run = _RunRecorder(
|
||||
responses=[
|
||||
(0, "", ""),
|
||||
(0, "kernelspec installed\n", ""),
|
||||
(0, "4242\n", ""),
|
||||
(0, "http://127.0.0.1:8891/lab?token=t\n", ""),
|
||||
]
|
||||
)
|
||||
alive: set = {7777}
|
||||
manager = _build_manager(
|
||||
popen=popen, run=run, alive_pids=alive, tokens=["t"], local_port=22222
|
||||
)
|
||||
workspace_root = "/srv/proj-no-key"
|
||||
expected = "sessions-" + _hashlib.sha1(workspace_root.encode()).hexdigest()[:12]
|
||||
|
||||
info = manager.ensure_started(
|
||||
"dev",
|
||||
workspace_root,
|
||||
kernel_python="/opt/python",
|
||||
)
|
||||
|
||||
assert info.kernel_name == expected
|
||||
monkeypatch.setenv("SESSIONS_MARIMO_STARTUP_TIMEOUT_S", "not-a-number")
|
||||
try:
|
||||
reloaded = importlib.reload(mh)
|
||||
assert reloaded._resolve_startup_timeout_seconds() == 60.0
|
||||
finally:
|
||||
monkeypatch.delenv("SESSIONS_MARIMO_STARTUP_TIMEOUT_S", raising=False)
|
||||
importlib.reload(mh)
|
||||
@@ -49,10 +49,6 @@ def test_plugin_entrypoint_exports_sessions_commands() -> None:
|
||||
sys.modules.update(original_modules)
|
||||
|
||||
assert plugin_module.__all__ == [
|
||||
"SessionsAgentLayoutCollapseSwitcherCommand",
|
||||
"SessionsAgentLayoutCommand",
|
||||
"SessionsAgentSwitcherClickListener",
|
||||
"SessionsAttachRemoteTmuxCommand",
|
||||
"SessionsBridgeLifecycleListener",
|
||||
"SessionsClearPythonInterpreterCommand",
|
||||
"SessionsConnectRemoteWorkspaceCommand",
|
||||
@@ -60,39 +56,29 @@ def test_plugin_entrypoint_exports_sessions_commands() -> None:
|
||||
"SessionsDiagnoseLspWorkspaceCommand",
|
||||
"SessionsExpandDeferredDirectoryCommand",
|
||||
"SessionsInstallRemoteExtensionCommand",
|
||||
"SessionsKillAgentSessionCommand",
|
||||
"SessionsKillRemoteTerminalCommand",
|
||||
"SessionsLspNavigationListener",
|
||||
"SessionsNewAgentSessionCommand",
|
||||
"SessionsNewRemoteTerminalPaneCommand",
|
||||
"SessionsOnDemandFetchListener",
|
||||
"SessionsOpenLocalSshConfigCommand",
|
||||
"SessionsOpenRecentRemoteWorkspaceCommand",
|
||||
"SessionsOpenRemoteFileCommand",
|
||||
"SessionsOpenRemoteFolderCommand",
|
||||
"SessionsOpenRemoteJupyterCommand",
|
||||
"SessionsOpenRemoteMarimoCommand",
|
||||
"SessionsOpenRemoteTerminalCommand",
|
||||
"SessionsOpenRemoteTreeCommand",
|
||||
"SessionsOpenSettingsCommand",
|
||||
"SessionsPreviewRemoteAgentPayloadCommand",
|
||||
"SessionsOpenRecentRemoteWorkspaceCommand",
|
||||
"SessionsOpenLocalSshConfigCommand",
|
||||
"SessionsPythonInterpreterStatusListener",
|
||||
"SessionsReconnectCurrentWorkspaceCommand",
|
||||
"SessionsRegisterJupyterKernelCommand",
|
||||
"SessionsRemoteCachedFileSaveListener",
|
||||
"SessionsRemoteExtensionStatusCommand",
|
||||
"SessionsRemoteTreeActivateCommand",
|
||||
"SessionsRemoteTreeEventListener",
|
||||
"SessionsRemoteTreeRefreshCommand",
|
||||
"SessionsRemoveRemoteExtensionCommand",
|
||||
"SessionsRenderAgentSwitcherCommand",
|
||||
"SessionsSelectPythonInterpreterCommand",
|
||||
"SessionsSetupRemoteDebuggingCommand",
|
||||
"SessionsShowAgentSwitcherCommand",
|
||||
"SessionsSidebarPlaceholderHydrateListener",
|
||||
"SessionsStopRemoteJupyterCommand",
|
||||
"SessionsSwitchAgentSessionCommand",
|
||||
"SessionsStopRemoteMarimoCommand",
|
||||
"SessionsSyncRemoteTreeToSidebarCommand",
|
||||
"SessionsTerminalLinkClickListener",
|
||||
"SessionsWorkspaceActivationListener",
|
||||
]
|
||||
assert plugin_module.SessionsConnectRemoteWorkspaceCommand.__name__ == (
|
||||
@@ -110,15 +96,6 @@ def test_plugin_entrypoint_exports_sessions_commands() -> None:
|
||||
assert plugin_module.SessionsOpenRemoteTerminalCommand.__name__ == (
|
||||
"SessionsOpenRemoteTerminalCommand"
|
||||
)
|
||||
assert plugin_module.SessionsNewRemoteTerminalPaneCommand.__name__ == (
|
||||
"SessionsNewRemoteTerminalPaneCommand"
|
||||
)
|
||||
assert plugin_module.SessionsKillRemoteTerminalCommand.__name__ == (
|
||||
"SessionsKillRemoteTerminalCommand"
|
||||
)
|
||||
assert plugin_module.SessionsAttachRemoteTmuxCommand.__name__ == (
|
||||
"SessionsAttachRemoteTmuxCommand"
|
||||
)
|
||||
assert plugin_module.SessionsOpenRemoteFileCommand.__name__ == (
|
||||
"SessionsOpenRemoteFileCommand"
|
||||
)
|
||||
@@ -131,9 +108,6 @@ def test_plugin_entrypoint_exports_sessions_commands() -> None:
|
||||
assert plugin_module.SessionsOpenRemoteTreeCommand.__name__ == (
|
||||
"SessionsOpenRemoteTreeCommand"
|
||||
)
|
||||
assert plugin_module.SessionsPreviewRemoteAgentPayloadCommand.__name__ == (
|
||||
"SessionsPreviewRemoteAgentPayloadCommand"
|
||||
)
|
||||
assert plugin_module.SessionsRemoteTreeRefreshCommand.__name__ == (
|
||||
"SessionsRemoteTreeRefreshCommand"
|
||||
)
|
||||
|
||||
@@ -37,7 +37,10 @@ def test_options_defaults() -> None:
|
||||
|
||||
|
||||
def test_builtin_ignores_include_common_heavy_directories() -> None:
|
||||
assert ".git" in MIRROR_BUILTIN_IGNORE_PATTERNS
|
||||
# ``.git`` is intentionally NOT in the builtin list — the local cache is
|
||||
# meant to be usable as a working tree under Sublime Merge / git GUIs,
|
||||
# which need ``.git/`` mirrored from the remote.
|
||||
assert ".git" not in MIRROR_BUILTIN_IGNORE_PATTERNS
|
||||
assert "node_modules" in MIRROR_BUILTIN_IGNORE_PATTERNS
|
||||
assert "__pycache__" in MIRROR_BUILTIN_IGNORE_PATTERNS
|
||||
assert ".uv-python" in MIRROR_BUILTIN_IGNORE_PATTERNS
|
||||
@@ -59,7 +62,6 @@ def test_merge_with_empty_settings_yields_only_builtin() -> None:
|
||||
def test_builtin_ignores_full_snapshot() -> None:
|
||||
"""Pin the exact builtin ignore list so additions require explicit update."""
|
||||
expected = (
|
||||
".git",
|
||||
"node_modules",
|
||||
"__pycache__",
|
||||
".venv",
|
||||
|
||||
@@ -40,10 +40,6 @@ def test_sessions_plugin_imports_under_sublime_style_package_layout() -> None:
|
||||
|
||||
plugin_module = importlib.import_module("Sessions.plugin")
|
||||
assert plugin_module.__all__ == [
|
||||
"SessionsAgentLayoutCollapseSwitcherCommand",
|
||||
"SessionsAgentLayoutCommand",
|
||||
"SessionsAgentSwitcherClickListener",
|
||||
"SessionsAttachRemoteTmuxCommand",
|
||||
"SessionsBridgeLifecycleListener",
|
||||
"SessionsClearPythonInterpreterCommand",
|
||||
"SessionsConnectRemoteWorkspaceCommand",
|
||||
@@ -51,39 +47,29 @@ def test_sessions_plugin_imports_under_sublime_style_package_layout() -> None:
|
||||
"SessionsDiagnoseLspWorkspaceCommand",
|
||||
"SessionsExpandDeferredDirectoryCommand",
|
||||
"SessionsInstallRemoteExtensionCommand",
|
||||
"SessionsKillAgentSessionCommand",
|
||||
"SessionsKillRemoteTerminalCommand",
|
||||
"SessionsLspNavigationListener",
|
||||
"SessionsNewAgentSessionCommand",
|
||||
"SessionsNewRemoteTerminalPaneCommand",
|
||||
"SessionsOnDemandFetchListener",
|
||||
"SessionsOpenLocalSshConfigCommand",
|
||||
"SessionsOpenRecentRemoteWorkspaceCommand",
|
||||
"SessionsOpenRemoteFileCommand",
|
||||
"SessionsOpenRemoteFolderCommand",
|
||||
"SessionsOpenRemoteJupyterCommand",
|
||||
"SessionsOpenRemoteMarimoCommand",
|
||||
"SessionsOpenRemoteTerminalCommand",
|
||||
"SessionsOpenRemoteTreeCommand",
|
||||
"SessionsOpenSettingsCommand",
|
||||
"SessionsPreviewRemoteAgentPayloadCommand",
|
||||
"SessionsOpenRecentRemoteWorkspaceCommand",
|
||||
"SessionsOpenLocalSshConfigCommand",
|
||||
"SessionsPythonInterpreterStatusListener",
|
||||
"SessionsReconnectCurrentWorkspaceCommand",
|
||||
"SessionsRegisterJupyterKernelCommand",
|
||||
"SessionsRemoteCachedFileSaveListener",
|
||||
"SessionsRemoteExtensionStatusCommand",
|
||||
"SessionsRemoteTreeActivateCommand",
|
||||
"SessionsRemoteTreeEventListener",
|
||||
"SessionsRemoteTreeRefreshCommand",
|
||||
"SessionsRemoveRemoteExtensionCommand",
|
||||
"SessionsRenderAgentSwitcherCommand",
|
||||
"SessionsSelectPythonInterpreterCommand",
|
||||
"SessionsSetupRemoteDebuggingCommand",
|
||||
"SessionsShowAgentSwitcherCommand",
|
||||
"SessionsSidebarPlaceholderHydrateListener",
|
||||
"SessionsStopRemoteJupyterCommand",
|
||||
"SessionsSwitchAgentSessionCommand",
|
||||
"SessionsStopRemoteMarimoCommand",
|
||||
"SessionsSyncRemoteTreeToSidebarCommand",
|
||||
"SessionsTerminalLinkClickListener",
|
||||
"SessionsWorkspaceActivationListener",
|
||||
]
|
||||
finally:
|
||||
|
||||
@@ -1,341 +0,0 @@
|
||||
"""Tests for the hover-activation logic in ``terminal_link_click``.
|
||||
|
||||
The hover loop paints a ``markup.underline.link`` region under the
|
||||
token the cursor is over and erases it on hover-off. Real Sublime hover
|
||||
events aren't available in the Linux CI, so we drive the listener
|
||||
through :func:`sessions.terminal_link_click.process_hover` with a
|
||||
FakeView that records ``add_regions`` / ``erase_regions`` calls.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import pytest
|
||||
from sessions import terminal_link_click
|
||||
from sessions.terminal_link_click import (
|
||||
SessionsTerminalLinkClickListener,
|
||||
process_hover,
|
||||
)
|
||||
|
||||
# Sublime's ``HOVER_TEXT`` value is 1; the module falls back to 1 when
|
||||
# ``sublime`` isn't importable.
|
||||
HOVER_TEXT = 1
|
||||
HOVER_GUTTER = 2
|
||||
|
||||
|
||||
class _FakeRegion:
|
||||
def __init__(self, start: int, end: int) -> None:
|
||||
self._start = start
|
||||
self._end = end
|
||||
|
||||
def begin(self) -> int:
|
||||
return self._start
|
||||
|
||||
def end(self) -> int:
|
||||
return self._end
|
||||
|
||||
|
||||
class _FakeSettings:
|
||||
def __init__(self, terminus: bool) -> None:
|
||||
self._values: Dict[str, Any] = {"terminus_view": terminus}
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
return self._values.get(key, default)
|
||||
|
||||
def set(self, key: str, value: Any) -> None:
|
||||
self._values[key] = value
|
||||
|
||||
|
||||
class _FakeHoverView:
|
||||
"""Single-line Terminus-like view that records region side effects."""
|
||||
|
||||
_next_id = 1000
|
||||
|
||||
def __init__(self, text: str, *, terminus: bool = True) -> None:
|
||||
self._text = text
|
||||
self._settings = _FakeSettings(terminus=terminus)
|
||||
self.added_regions: List[Tuple[str, Any, str, int]] = []
|
||||
self.erased_region_keys: List[str] = []
|
||||
self._id = _FakeHoverView._next_id
|
||||
_FakeHoverView._next_id += 1
|
||||
|
||||
def id(self) -> int:
|
||||
return self._id
|
||||
|
||||
def line(self, point: int) -> _FakeRegion:
|
||||
return _FakeRegion(0, len(self._text))
|
||||
|
||||
def substr(self, region: _FakeRegion) -> str:
|
||||
return self._text[region.begin() : region.end()]
|
||||
|
||||
def settings(self) -> _FakeSettings:
|
||||
return self._settings
|
||||
|
||||
def add_regions(
|
||||
self,
|
||||
key: str,
|
||||
regions: Any,
|
||||
scope: str,
|
||||
icon: str,
|
||||
flags: int,
|
||||
) -> None:
|
||||
_ = icon
|
||||
# Materialise the iterable to a tuple so tests can inspect the
|
||||
# stored state after subsequent calls.
|
||||
self.added_regions.append((key, tuple(regions), scope, flags))
|
||||
|
||||
def erase_regions(self, key: str) -> None:
|
||||
self.erased_region_keys.append(key)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_hover_state():
|
||||
"""Clear the module-level hover-state dict between tests."""
|
||||
terminal_link_click._HOVER_STATE.clear()
|
||||
yield
|
||||
terminal_link_click._HOVER_STATE.clear()
|
||||
|
||||
|
||||
# --- process_hover -----------------------------------------------------------
|
||||
|
||||
|
||||
def test_hover_paints_link_region_over_url_token() -> None:
|
||||
view = _FakeHoverView("See https://docs.example.com/x for more.")
|
||||
# Point inside the URL token.
|
||||
result = process_hover(view, point=10, hover_zone=HOVER_TEXT)
|
||||
assert result == ("url", "https://docs.example.com/x")
|
||||
# Underline region painted under the URL span exactly.
|
||||
assert len(view.added_regions) == 1
|
||||
key, regions, scope, _flags = view.added_regions[0]
|
||||
assert key == "sessions_terminal_link"
|
||||
# Sublime uses ``markup.underline.link`` as the link-color scope.
|
||||
assert scope == "markup.underline.link"
|
||||
assert len(regions) == 1
|
||||
start, end = regions[0]
|
||||
assert view._text[start:end] == "https://docs.example.com/x"
|
||||
|
||||
|
||||
def test_hover_paints_link_region_over_abspath_token() -> None:
|
||||
view = _FakeHoverView("Traceback: /srv/app/a.py:42")
|
||||
result = process_hover(view, point=16, hover_zone=HOVER_TEXT)
|
||||
assert result == ("abspath", "/srv/app/a.py")
|
||||
key, regions, _scope, _flags = view.added_regions[0]
|
||||
assert key == "sessions_terminal_link"
|
||||
start, end = regions[0]
|
||||
# The painted span covers the full token (including the ``:42``
|
||||
# suffix) so the underline matches what the user sees — the
|
||||
# classifier discards the suffix internally but hover paints the
|
||||
# whole clickable token.
|
||||
assert view._text[start:end] == "/srv/app/a.py:42"
|
||||
|
||||
|
||||
def test_hover_erases_region_when_token_not_clickable() -> None:
|
||||
view = _FakeHoverView("just a normal terminal line")
|
||||
# Prime the state as if a previous hover had painted something.
|
||||
view.add_regions(
|
||||
"sessions_terminal_link", [_FakeRegion(0, 4)], "markup.underline.link", "", 0
|
||||
)
|
||||
view.added_regions.clear()
|
||||
result = process_hover(view, point=5, hover_zone=HOVER_TEXT)
|
||||
assert result is None
|
||||
# Erased on hover-off (token is not a URL / abspath).
|
||||
assert "sessions_terminal_link" in view.erased_region_keys
|
||||
|
||||
|
||||
def test_hover_skips_non_terminus_views() -> None:
|
||||
view = _FakeHoverView("https://example.com/a", terminus=False)
|
||||
result = process_hover(view, point=5, hover_zone=HOVER_TEXT)
|
||||
assert result is None
|
||||
# No region painted on a non-Terminus view.
|
||||
assert not view.added_regions
|
||||
|
||||
|
||||
def test_hover_ignores_non_text_hover_zones() -> None:
|
||||
view = _FakeHoverView("https://example.com/a")
|
||||
result = process_hover(view, point=5, hover_zone=HOVER_GUTTER)
|
||||
assert result is None
|
||||
assert not view.added_regions
|
||||
|
||||
|
||||
def test_hover_replaces_prior_region_when_mouse_moves() -> None:
|
||||
view = _FakeHoverView("https://a.example /srv/b.py")
|
||||
# First hover lands on the URL.
|
||||
process_hover(view, point=5, hover_zone=HOVER_TEXT)
|
||||
# Second hover lands on the path.
|
||||
process_hover(view, point=22, hover_zone=HOVER_TEXT)
|
||||
# Two paints, each with the same region key so Sublime replaces
|
||||
# the underline each time.
|
||||
assert len(view.added_regions) == 2
|
||||
assert all(entry[0] == "sessions_terminal_link" for entry in view.added_regions)
|
||||
|
||||
|
||||
def test_hover_state_drops_on_close() -> None:
|
||||
view = _FakeHoverView("https://example.com/x")
|
||||
process_hover(view, point=5, hover_zone=HOVER_TEXT)
|
||||
assert view.id() in terminal_link_click._HOVER_STATE
|
||||
listener = SessionsTerminalLinkClickListener()
|
||||
listener.on_close(view)
|
||||
assert view.id() not in terminal_link_click._HOVER_STATE
|
||||
# Erase side-effect also fires so the pane never orphans the region.
|
||||
assert "sessions_terminal_link" in view.erased_region_keys
|
||||
|
||||
|
||||
# --- click fast path --------------------------------------------------------
|
||||
|
||||
|
||||
class _FakeClickView(_FakeHoverView):
|
||||
"""Terminus view that also exposes ``window_to_text`` + ``window``."""
|
||||
|
||||
def __init__(self, text: str) -> None:
|
||||
super().__init__(text, terminus=True)
|
||||
self.window_value: Optional[object] = None
|
||||
|
||||
def window_to_text(self, xy) -> int:
|
||||
# Tests pass ``x`` as the direct character offset for
|
||||
# determinism; ``y`` is ignored.
|
||||
return int(xy[0])
|
||||
|
||||
def window(self) -> Optional[object]:
|
||||
return self.window_value
|
||||
|
||||
|
||||
class _FakeClickWindow:
|
||||
def __init__(self) -> None:
|
||||
self.commands: List[Tuple[str, Dict[str, Any]]] = []
|
||||
|
||||
def run_command(self, name: str, args: Dict[str, Any]) -> None:
|
||||
self.commands.append((name, args))
|
||||
|
||||
|
||||
def _click_event(point: int) -> Dict[str, Any]:
|
||||
return {"x": point, "y": 0, "modifier_keys": {"primary": True}}
|
||||
|
||||
|
||||
def test_click_reuses_active_hover_region(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
view = _FakeClickView("open /srv/app/a.py now")
|
||||
window = _FakeClickWindow()
|
||||
view.window_value = window
|
||||
# Hover paints the region + records state first.
|
||||
process_hover(view, point=10, hover_zone=HOVER_TEXT)
|
||||
assert view.id() in terminal_link_click._HOVER_STATE
|
||||
|
||||
# Sentinel: ensure the click path does NOT re-run classification
|
||||
# when a cached hover span covers the click point.
|
||||
calls: List[str] = []
|
||||
real_classify = terminal_link_click.classify_terminal_token
|
||||
|
||||
def tracer(token: str) -> Any:
|
||||
calls.append(token)
|
||||
return real_classify(token)
|
||||
|
||||
monkeypatch.setattr(terminal_link_click, "classify_terminal_token", tracer)
|
||||
|
||||
listener = SessionsTerminalLinkClickListener()
|
||||
listener.on_text_command(view, "drag_select", {"event": _click_event(10)})
|
||||
|
||||
assert calls == [], "click should re-use hover classification, not re-classify"
|
||||
assert window.commands == [("open_file", {"file": "/srv/app/a.py"})]
|
||||
|
||||
|
||||
def test_click_falls_back_to_classification_when_hover_absent() -> None:
|
||||
view = _FakeClickView("open /srv/app/b.py now")
|
||||
window = _FakeClickWindow()
|
||||
view.window_value = window
|
||||
# No hover recorded — ``_HOVER_STATE`` is empty.
|
||||
listener = SessionsTerminalLinkClickListener()
|
||||
listener.on_text_command(view, "drag_select", {"event": _click_event(10)})
|
||||
assert window.commands == [("open_file", {"file": "/srv/app/b.py"})]
|
||||
|
||||
|
||||
def test_click_ignores_non_primary_modifier() -> None:
|
||||
view = _FakeClickView("open /srv/app/c.py now")
|
||||
window = _FakeClickWindow()
|
||||
view.window_value = window
|
||||
event = {"x": 10, "y": 0, "modifier_keys": {"primary": False}}
|
||||
listener = SessionsTerminalLinkClickListener()
|
||||
listener.on_text_command(view, "drag_select", {"event": event})
|
||||
assert window.commands == []
|
||||
|
||||
|
||||
def test_click_outside_hover_region_still_classifies() -> None:
|
||||
# Hover painted on one token; click lands outside that span on a
|
||||
# different token — the listener must re-classify rather than use
|
||||
# the stale hover record.
|
||||
view = _FakeClickView("/srv/a.py /srv/b.py")
|
||||
window = _FakeClickWindow()
|
||||
view.window_value = window
|
||||
process_hover(view, point=2, hover_zone=HOVER_TEXT)
|
||||
listener = SessionsTerminalLinkClickListener()
|
||||
# Point 15 is inside ``/srv/b.py``.
|
||||
listener.on_text_command(view, "drag_select", {"event": _click_event(15)})
|
||||
assert window.commands == [("open_file", {"file": "/srv/b.py"})]
|
||||
|
||||
|
||||
def test_click_on_abspath_suppresses_drag_select() -> None:
|
||||
# Regression: in v0.5.x hover painted the box but Cmd+click failed
|
||||
# to open the file because the underlying ``drag_select`` ran in
|
||||
# parallel and clobbered the open. The listener must return
|
||||
# ``("noop", {})`` from ``on_text_command`` to suppress drag_select
|
||||
# whenever it dispatches a link.
|
||||
view = _FakeClickView("open /srv/app/x.py now")
|
||||
window = _FakeClickWindow()
|
||||
view.window_value = window
|
||||
listener = SessionsTerminalLinkClickListener()
|
||||
result = listener.on_text_command(view, "drag_select", {"event": _click_event(10)})
|
||||
assert result == ("noop", {})
|
||||
assert window.commands == [("open_file", {"file": "/srv/app/x.py"})]
|
||||
|
||||
|
||||
def test_click_on_url_suppresses_drag_select(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# Same regression contract for URL clicks — the browser open path
|
||||
# must not let drag_select also run.
|
||||
view = _FakeClickView("see https://example.com/x for more")
|
||||
view.window_value = _FakeClickWindow()
|
||||
opened: List[str] = []
|
||||
monkeypatch.setattr(
|
||||
terminal_link_click,
|
||||
"_handle_url",
|
||||
lambda url: opened.append(url),
|
||||
)
|
||||
listener = SessionsTerminalLinkClickListener()
|
||||
# Point 6 lands inside the URL span.
|
||||
result = listener.on_text_command(view, "drag_select", {"event": _click_event(6)})
|
||||
assert result == ("noop", {})
|
||||
assert opened == ["https://example.com/x"]
|
||||
|
||||
|
||||
def test_click_on_localhost_url_opens_browser(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
# Cluster B fix: scheme-less ``localhost:PORT`` should round-trip
|
||||
# through ``_handle_url`` as ``http://localhost:PORT/`` so the browser
|
||||
# picks it up like any other URL. The trailing slash is load-bearing
|
||||
# on macOS — without it ``open location`` falls back to
|
||||
# ``about:blank`` (the v0.6.4 regression).
|
||||
view = _FakeClickView("Server up at localhost:8080 now")
|
||||
view.window_value = _FakeClickWindow()
|
||||
opened: List[str] = []
|
||||
monkeypatch.setattr(
|
||||
terminal_link_click,
|
||||
"_handle_url",
|
||||
lambda url: opened.append(url),
|
||||
)
|
||||
listener = SessionsTerminalLinkClickListener()
|
||||
# Point 14 lands inside the ``localhost:8080`` token (offset of ``l``).
|
||||
result = listener.on_text_command(view, "drag_select", {"event": _click_event(14)})
|
||||
assert result == ("noop", {})
|
||||
assert opened == ["http://localhost:8080/"]
|
||||
|
||||
|
||||
def test_click_on_non_link_token_falls_through_to_drag_select() -> None:
|
||||
# Sanity: clicking on plain text must NOT suppress drag_select. Users
|
||||
# still need to be able to select text in a terminal pane with
|
||||
# Cmd+click for native multi-cursor (or whatever Terminus binds it to).
|
||||
view = _FakeClickView("plain text here")
|
||||
view.window_value = _FakeClickWindow()
|
||||
listener = SessionsTerminalLinkClickListener()
|
||||
# Click on "plain" (offset 2) — not a link token.
|
||||
result = listener.on_text_command(view, "drag_select", {"event": _click_event(2)})
|
||||
assert result is None # drag_select runs as normal
|
||||
assert view.window_value.commands == [] # nothing dispatched
|
||||
@@ -1,517 +0,0 @@
|
||||
"""Unit tests for the Terminus Cmd+click link handler."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import pytest
|
||||
from sessions import terminal_link_click
|
||||
from sessions.terminal_link_click import (
|
||||
_strip_ansi,
|
||||
classify_terminal_token,
|
||||
classify_with_context,
|
||||
extract_token_at,
|
||||
)
|
||||
|
||||
# --- classify_terminal_token -------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"token, expected",
|
||||
[
|
||||
("https://example.com/foo?bar=1", ("url", "https://example.com/foo?bar=1")),
|
||||
("http://localhost:8888/notebooks/a.ipynb?token=abc", None),
|
||||
(
|
||||
"http://localhost:8888/",
|
||||
("url", "http://localhost:8888/"),
|
||||
),
|
||||
(
|
||||
"ftp://mirror.example.com/file.iso",
|
||||
("url", "ftp://mirror.example.com/file.iso"),
|
||||
),
|
||||
("file:///tmp/x.txt", ("url", "file:///tmp/x.txt")),
|
||||
("HTTPS://UPPER.example.com/", ("url", "HTTPS://UPPER.example.com/")),
|
||||
],
|
||||
)
|
||||
def test_classify_token_handles_urls(token, expected) -> None:
|
||||
# The 2nd case has a ``?`` — our URL regex permits it, so fix the
|
||||
# expected value inline rather than skip.
|
||||
if token.startswith("http://localhost:8888/notebooks/"):
|
||||
expected = ("url", token)
|
||||
assert classify_terminal_token(token) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"token, expected_path",
|
||||
[
|
||||
("/srv/app/pkg/a.py", "/srv/app/pkg/a.py"),
|
||||
("/usr/include/stdio.h", "/usr/include/stdio.h"),
|
||||
# grep -n style with line number — path part only; line suffix is
|
||||
# accepted by the regex but discarded for now.
|
||||
("/srv/app/pkg/a.py:42", "/srv/app/pkg/a.py"),
|
||||
("/srv/app/pkg/a.py:42:7", "/srv/app/pkg/a.py"),
|
||||
],
|
||||
)
|
||||
def test_classify_token_handles_absolute_remote_paths(token, expected_path) -> None:
|
||||
result = classify_terminal_token(token)
|
||||
assert result is not None
|
||||
kind, value = result
|
||||
assert kind == "abspath"
|
||||
assert value == expected_path
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"token",
|
||||
[
|
||||
"",
|
||||
" ",
|
||||
"just-a-word",
|
||||
"./relative/path",
|
||||
"../parent",
|
||||
"/", # root alone is not a file to open
|
||||
"ssh://host/foo", # not in our URL allowlist
|
||||
"scp://host:/file",
|
||||
"C:\\Windows\\file.py", # Windows drive, not our supported shape
|
||||
],
|
||||
)
|
||||
def test_classify_token_rejects_non_clickable(token) -> None:
|
||||
assert classify_terminal_token(token) is None
|
||||
|
||||
|
||||
def test_classify_token_trims_trailing_punctuation() -> None:
|
||||
# Real terminal output often has sentences ending in ``.``, commas, etc.
|
||||
# around URLs/paths. We strip the outermost punctuation before matching.
|
||||
assert classify_terminal_token("https://example.com/foo.") == (
|
||||
"url",
|
||||
"https://example.com/foo",
|
||||
)
|
||||
assert classify_terminal_token("/srv/app/pkg/a.py.") == (
|
||||
"abspath",
|
||||
"/srv/app/pkg/a.py",
|
||||
)
|
||||
|
||||
|
||||
# --- scheme-less host:port URLs ---------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"token, expected_url",
|
||||
[
|
||||
# Bare localhost:port — the canonical dev-server case. We always
|
||||
# emit a trailing slash on the path because macOS' ``open
|
||||
# location`` (driving Safari/Chrome via AppleScript) treats a
|
||||
# bare host:port URL as under-specified and falls back to
|
||||
# ``about:blank``.
|
||||
("localhost:8080", "http://localhost:8080/"),
|
||||
("localhost:8888/notebooks/a.ipynb", "http://localhost:8888/notebooks/a.ipynb"),
|
||||
# 127.0.0.1 is what Jupyter's startup banner prints.
|
||||
("127.0.0.1:8888", "http://127.0.0.1:8888/"),
|
||||
("127.0.0.1:5173/", "http://127.0.0.1:5173/"),
|
||||
# Arbitrary IPv4 that shows up in ML / Ray / dashboard links.
|
||||
("10.0.0.4:9000", "http://10.0.0.4:9000/"),
|
||||
# Trailing punctuation from prose strips before matching.
|
||||
("localhost:3000.", "http://localhost:3000/"),
|
||||
# ``0.0.0.0`` is a wildcard bind address — servers print it to
|
||||
# mean "listening on every interface" but browsers can't route
|
||||
# to it. Canonicalize to ``localhost`` so the click lands on
|
||||
# the loopback listener the user actually wants.
|
||||
("0.0.0.0:8080", "http://localhost:8080/"),
|
||||
("0.0.0.0:8080/", "http://localhost:8080/"),
|
||||
("0.0.0.0:8080/dashboard", "http://localhost:8080/dashboard"),
|
||||
],
|
||||
)
|
||||
def test_classify_token_handles_localhost_and_host_port(
|
||||
token: str, expected_url: str
|
||||
) -> None:
|
||||
assert classify_terminal_token(token) == ("url", expected_url)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"token",
|
||||
[
|
||||
# A bare hostname with no port must not match — too many false
|
||||
# positives in normal terminal output (``var:42`` etc.).
|
||||
"localhost",
|
||||
# Port out of range.
|
||||
"localhost:99999",
|
||||
# ``host:line`` style (no slash, port too high) — should not
|
||||
# masquerade as a URL.
|
||||
"myvar:42",
|
||||
# Word-shaped host with a colon — distinguish from the
|
||||
# localhost / IPv4 allow-list. ``foo.example.com:80`` is a valid
|
||||
# URL idea but we ask the user to type ``http://`` explicitly so
|
||||
# we don't promote arbitrary hostnames in terminal output.
|
||||
"foo.example.com:8080",
|
||||
# IPv4 with octet > 255 still gets accepted by the regex but
|
||||
# the test below documents that we don't try to validate octets;
|
||||
# callers expect a raw browser navigation, which will fail
|
||||
# gracefully for invalid IPs. Skip in the *reject* set —
|
||||
# see the dedicated test below.
|
||||
],
|
||||
)
|
||||
def test_classify_token_rejects_non_url_host_port_shapes(token: str) -> None:
|
||||
assert classify_terminal_token(token) is None
|
||||
|
||||
|
||||
def test_classify_token_localhost_does_not_collide_with_abspath() -> None:
|
||||
# ``/srv/.../localhost:8080`` is an abspath on disk — host:port
|
||||
# detection must only fire on tokens that don't start with ``/``.
|
||||
result = classify_terminal_token("/srv/etc/localhost:8080")
|
||||
assert result is not None
|
||||
kind, value = result
|
||||
assert kind == "abspath"
|
||||
# Path part stops before the ``:8080`` suffix per the abspath regex.
|
||||
assert value == "/srv/etc/localhost"
|
||||
|
||||
|
||||
# --- adversarial: the v0.6.4 ``about:blank-`` regression --------------------
|
||||
|
||||
|
||||
def test_classify_token_zero_host_canonicalizes_to_localhost() -> None:
|
||||
# Repro for v0.6.4 macOS bug: ``python3 -m http.server 8080`` prints
|
||||
# ``Serving HTTP on 0.0.0.0 port 8080 (http://0.0.0.0:8080/) ...``.
|
||||
# macOS browsers can't route to ``0.0.0.0`` and fall back to
|
||||
# ``about:blank``; canonicalize to loopback so Cmd+click reaches
|
||||
# the listener the user actually wants.
|
||||
assert classify_terminal_token("0.0.0.0:8080") == (
|
||||
"url",
|
||||
"http://localhost:8080/",
|
||||
)
|
||||
|
||||
|
||||
def test_classify_token_bare_host_port_emits_trailing_slash() -> None:
|
||||
# Without a trailing slash the macOS ``open location`` AppleScript
|
||||
# treats the URL as under-specified and the browser shows a stray
|
||||
# leftover suffix (the v0.6.4 ``about:blank-`` symptom). The
|
||||
# promotion path always emits a canonical ``/`` when no path
|
||||
# component is present so every platform sees a well-formed URL.
|
||||
assert classify_terminal_token("localhost:8080") == (
|
||||
"url",
|
||||
"http://localhost:8080/",
|
||||
)
|
||||
assert classify_terminal_token("127.0.0.1:8080") == (
|
||||
"url",
|
||||
"http://127.0.0.1:8080/",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"token",
|
||||
[
|
||||
# A trailing dash glued to the port has no canonical
|
||||
# interpretation as a URL — better to refuse than guess. The
|
||||
# v0.6.4 ``about:blank-`` symptom on macOS came from passing a
|
||||
# malformed token straight to the browser; rejecting here means
|
||||
# the click falls through to plain text selection.
|
||||
"localhost:8080-extra",
|
||||
"localhost:8080-",
|
||||
"127.0.0.1:8080-",
|
||||
"0.0.0.0:8080-",
|
||||
],
|
||||
)
|
||||
def test_classify_token_rejects_dash_glued_to_port(token: str) -> None:
|
||||
assert classify_terminal_token(token) is None
|
||||
|
||||
|
||||
def test_http_server_banner_line_classifies_only_clean_url_tokens() -> None:
|
||||
# The whole-line scenario for ``python3 -m http.server 8080``:
|
||||
# ``Serving HTTP on 0.0.0.0 port 8080 (http://0.0.0.0:8080/) ...``.
|
||||
# We feed each whitespace-delimited token to the classifier. The
|
||||
# bare ``0.0.0.0`` (no port) and bare ``8080`` (no host) are noise;
|
||||
# the parens-wrapped URL is rejected because we deliberately don't
|
||||
# strip leading brackets (policy: hover precision over greediness).
|
||||
# Nothing should classify as a URL — the user clicks on a clean
|
||||
# ``localhost:8080`` token elsewhere in their pane (e.g. an explicit
|
||||
# echo, a Vite/Jupyter banner) and gets the canonical
|
||||
# ``http://localhost:8080/`` form below.
|
||||
line = "Serving HTTP on 0.0.0.0 port 8080 (http://0.0.0.0:8080/) ..."
|
||||
hits = []
|
||||
for token in line.split():
|
||||
result = classify_terminal_token(token)
|
||||
if result is not None:
|
||||
hits.append(result)
|
||||
# No token in *this* line promotes — the parens-wrapped URL is the
|
||||
# one the user would visually click on but our policy is to require
|
||||
# hover/click on the URL itself.
|
||||
assert hits == []
|
||||
# Sanity: the *bare* ``0.0.0.0:8080`` token (the load-bearing case
|
||||
# the v0.6.4 bug report covers) does promote, and it canonicalizes
|
||||
# to localhost with a trailing slash so macOS Safari/Chrome can
|
||||
# actually route it.
|
||||
assert classify_terminal_token("0.0.0.0:8080") == (
|
||||
"url",
|
||||
"http://localhost:8080/",
|
||||
)
|
||||
|
||||
|
||||
# --- extract_token_at --------------------------------------------------------
|
||||
|
||||
|
||||
class _FakeRegion:
|
||||
def __init__(self, start: int, end: int) -> None:
|
||||
self._start = start
|
||||
self._end = end
|
||||
|
||||
def begin(self) -> int:
|
||||
return self._start
|
||||
|
||||
def end(self) -> int:
|
||||
return self._end
|
||||
|
||||
|
||||
class _FakeTerminusView:
|
||||
"""Tiny stub of a Sublime view that exposes line()/substr()."""
|
||||
|
||||
def __init__(self, text: str) -> None:
|
||||
self._text = text
|
||||
|
||||
def line(self, point: int) -> _FakeRegion:
|
||||
# Single-line view — always return [0, len(text)].
|
||||
return _FakeRegion(0, len(self._text))
|
||||
|
||||
def substr(self, region: _FakeRegion) -> str:
|
||||
return self._text[region.begin() : region.end()]
|
||||
|
||||
|
||||
def test_extract_token_returns_whitespace_delimited_word() -> None:
|
||||
view = _FakeTerminusView(
|
||||
" Error at /srv/app/pkg/a.py:42 See https://docs/example "
|
||||
)
|
||||
# Point inside the path — should give the whole path token incl. :42.
|
||||
assert extract_token_at(view, 16) == "/srv/app/pkg/a.py:42"
|
||||
# Point inside the URL.
|
||||
assert extract_token_at(view, 45) == "https://docs/example"
|
||||
|
||||
|
||||
def test_extract_token_handles_point_at_line_boundary() -> None:
|
||||
view = _FakeTerminusView("/srv/app/a.py")
|
||||
assert extract_token_at(view, 0) == "/srv/app/a.py"
|
||||
assert extract_token_at(view, len("/srv/app/a.py")) == "/srv/app/a.py"
|
||||
|
||||
|
||||
def test_extract_token_at_whitespace_prefers_leftward_token() -> None:
|
||||
# When the click lands on a whitespace position, the extractor is
|
||||
# forgiving and picks the token immediately to the left — clicks
|
||||
# rarely hit pixel-perfect on terminal output. Point 4 sits on the
|
||||
# first space after "left", so the result is "left".
|
||||
view = _FakeTerminusView("left right")
|
||||
assert extract_token_at(view, 4) == "left"
|
||||
|
||||
|
||||
def test_extract_token_returns_none_between_two_spaces() -> None:
|
||||
# Only when both sides of ``point`` are whitespace does the
|
||||
# extractor give up.
|
||||
view = _FakeTerminusView("left right")
|
||||
assert extract_token_at(view, 5) is None
|
||||
|
||||
|
||||
def test_extract_token_returns_none_when_view_lacks_line_api() -> None:
|
||||
class _Bare:
|
||||
pass
|
||||
|
||||
assert extract_token_at(_Bare(), 0) is None
|
||||
|
||||
|
||||
# --- ANSI stripping (M1 (a) repro) -------------------------------------------
|
||||
|
||||
|
||||
def test_strip_ansi_handles_no_escapes_unchanged() -> None:
|
||||
# Allocation-free fast path: tokens without ESC must pass through
|
||||
# without invoking the regex.
|
||||
plain = "/srv/app/pkg/a.py"
|
||||
assert _strip_ansi(plain) is plain
|
||||
|
||||
|
||||
def test_strip_ansi_removes_color_csi_sequences() -> None:
|
||||
# ``ls --color=auto`` wraps filenames in CSI ``\x1b[01;34m`` ... ``\x1b[0m``.
|
||||
coloured = "\x1b[01;34m/srv/app\x1b[0m"
|
||||
assert _strip_ansi(coloured) == "/srv/app"
|
||||
|
||||
|
||||
def test_strip_ansi_removes_osc_and_simple_escapes() -> None:
|
||||
# ``\x1b]0;title\x07`` (OSC, ``set window title``) and ``\x1bM`` (Fe
|
||||
# single-byte escape, here ``RI`` reverse-index) cover the two
|
||||
# non-CSI families our stripper handles.
|
||||
osc = "before\x1b]0;some title\x07after"
|
||||
assert _strip_ansi(osc) == "beforeafter"
|
||||
simple = "x\x1bMy"
|
||||
assert _strip_ansi(simple) == "xy"
|
||||
|
||||
|
||||
def test_classify_token_strips_ansi_before_matching() -> None:
|
||||
# The headline M1 (a) repro: coloured ``ls`` output for an absolute
|
||||
# path must classify as ``("abspath", "/srv/app/pkg/a.py")``.
|
||||
coloured_abs = "\x1b[01;34m/srv/app/pkg/a.py\x1b[0m"
|
||||
assert classify_terminal_token(coloured_abs) == (
|
||||
"abspath",
|
||||
"/srv/app/pkg/a.py",
|
||||
)
|
||||
# Same shape for URLs that come through with a trailing reset.
|
||||
coloured_url = "\x1b[36mhttps://example.com/x\x1b[0m"
|
||||
assert classify_terminal_token(coloured_url) == (
|
||||
"url",
|
||||
"https://example.com/x",
|
||||
)
|
||||
|
||||
|
||||
# --- classify_with_context: relative-path against local cache mirror --------
|
||||
|
||||
|
||||
class _FakeRecentEntry:
|
||||
def __init__(self, remote_root: str) -> None:
|
||||
self.remote_root = remote_root
|
||||
self.host_alias = "fake-host"
|
||||
|
||||
|
||||
class _FakeWorkspaceContext:
|
||||
def __init__(self, remote_root: str, cache_root: Path) -> None:
|
||||
self.recent_entry = _FakeRecentEntry(remote_root)
|
||||
self.local_cache_root = cache_root
|
||||
self.cache_key = "fakekey"
|
||||
|
||||
|
||||
class _FakeWindow:
|
||||
def __init__(self, ctx: Optional[_FakeWorkspaceContext]) -> None:
|
||||
self._ctx = ctx
|
||||
self.commands: list = []
|
||||
|
||||
def run_command(self, name: str, args: Dict[str, Any]) -> None:
|
||||
self.commands.append((name, args))
|
||||
|
||||
|
||||
class _FakeViewWithWindow:
|
||||
def __init__(self, window: Optional[object]) -> None:
|
||||
self._window = window
|
||||
|
||||
def window(self) -> Optional[object]:
|
||||
return self._window
|
||||
|
||||
|
||||
def _patch_workspace_lookup(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
ctx: Optional[_FakeWorkspaceContext],
|
||||
) -> None:
|
||||
"""Stub the lazy ``commands._workspace_context`` import surface.
|
||||
|
||||
The production code imports the resolver lazily inside
|
||||
``_workspace_mirror_for_window``; we override the module-level
|
||||
helper directly so tests don't need to monkey with the real
|
||||
``sessions.commands`` surface.
|
||||
"""
|
||||
|
||||
def fake_lookup(window: object):
|
||||
if ctx is None:
|
||||
return None
|
||||
# Build a real ``RemoteToLocalCacheMapper`` so the round-trip
|
||||
# through ``local_path_for_remote_file`` exercises the same code
|
||||
# path the production resolver uses.
|
||||
from sessions.file_state import RemoteToLocalCacheMapper
|
||||
|
||||
mapper = RemoteToLocalCacheMapper(
|
||||
workspace_cache_key=ctx.cache_key,
|
||||
remote_workspace_root=ctx.recent_entry.remote_root,
|
||||
files_cache_root=ctx.local_cache_root,
|
||||
)
|
||||
return (mapper, ctx.recent_entry.remote_root, ctx.local_cache_root)
|
||||
|
||||
monkeypatch.setattr(
|
||||
terminal_link_click, "_workspace_mirror_for_window", fake_lookup
|
||||
)
|
||||
|
||||
|
||||
def test_classify_with_context_relpath_promotes_when_file_exists(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
# Stage a fake workspace cache mirror at <tmp_path>/cache with one file
|
||||
# at ``pkg/a.py`` (mirroring a remote ``/srv/app/pkg/a.py``).
|
||||
cache_root = tmp_path / "cache"
|
||||
(cache_root / "pkg").mkdir(parents=True)
|
||||
(cache_root / "pkg" / "a.py").write_text("# real file\n")
|
||||
ctx = _FakeWorkspaceContext("/srv/app", cache_root)
|
||||
window = _FakeWindow(ctx)
|
||||
view = _FakeViewWithWindow(window)
|
||||
_patch_workspace_lookup(monkeypatch, ctx)
|
||||
|
||||
result = classify_with_context(view, "pkg/a.py")
|
||||
assert result is not None
|
||||
kind, value = result
|
||||
assert kind == "relpath"
|
||||
assert Path(value) == cache_root / "pkg" / "a.py"
|
||||
|
||||
|
||||
def test_classify_with_context_relpath_skips_when_file_missing(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
# Cache mirror exists but the file does NOT — the classifier must
|
||||
# return ``None`` so the hover affordance never lights up for files
|
||||
# that aren't already materialized.
|
||||
cache_root = tmp_path / "cache"
|
||||
cache_root.mkdir()
|
||||
ctx = _FakeWorkspaceContext("/srv/app", cache_root)
|
||||
window = _FakeWindow(ctx)
|
||||
view = _FakeViewWithWindow(window)
|
||||
_patch_workspace_lookup(monkeypatch, ctx)
|
||||
|
||||
assert classify_with_context(view, "pkg/missing.py") is None
|
||||
|
||||
|
||||
def test_classify_with_context_relpath_skips_when_target_is_directory(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
# Directories must not promote — a click is supposed to open a file.
|
||||
cache_root = tmp_path / "cache"
|
||||
(cache_root / "pkg").mkdir(parents=True)
|
||||
ctx = _FakeWorkspaceContext("/srv/app", cache_root)
|
||||
window = _FakeWindow(ctx)
|
||||
view = _FakeViewWithWindow(window)
|
||||
_patch_workspace_lookup(monkeypatch, ctx)
|
||||
|
||||
assert classify_with_context(view, "pkg") is None
|
||||
|
||||
|
||||
def test_classify_with_context_relpath_rejects_parent_traversal(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
# ``../`` segments must never escape the workspace tree even if the
|
||||
# filesystem happens to contain a matching file.
|
||||
cache_root = tmp_path / "cache"
|
||||
cache_root.mkdir()
|
||||
ctx = _FakeWorkspaceContext("/srv/app", cache_root)
|
||||
window = _FakeWindow(ctx)
|
||||
view = _FakeViewWithWindow(window)
|
||||
_patch_workspace_lookup(monkeypatch, ctx)
|
||||
|
||||
assert classify_with_context(view, "../etc/passwd") is None
|
||||
assert classify_with_context(view, "..") is None
|
||||
|
||||
|
||||
def test_classify_with_context_relpath_skips_without_workspace(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
# When the window has no workspace context wired (e.g. plain Sublime
|
||||
# window with no Sessions metadata), relative-path classification
|
||||
# silently returns ``None`` rather than raising.
|
||||
window = _FakeWindow(None)
|
||||
view = _FakeViewWithWindow(window)
|
||||
_patch_workspace_lookup(monkeypatch, None)
|
||||
|
||||
assert classify_with_context(view, "pkg/a.py") is None
|
||||
|
||||
|
||||
def test_classify_with_context_falls_through_to_pure_classifier() -> None:
|
||||
# URL / abspath tokens must go through the pure classifier without
|
||||
# touching the workspace lookup at all (no monkeypatch needed).
|
||||
view = _FakeViewWithWindow(None)
|
||||
assert classify_with_context(view, "https://example.com/x") == (
|
||||
"url",
|
||||
"https://example.com/x",
|
||||
)
|
||||
assert classify_with_context(view, "/srv/app/pkg/a.py") == (
|
||||
"abspath",
|
||||
"/srv/app/pkg/a.py",
|
||||
)
|
||||
@@ -1,634 +0,0 @@
|
||||
"""Tests for ``sessions.terminal_tmux_session``.
|
||||
|
||||
The helper is Sublime-free; tests exercise session-name validation and
|
||||
the ``command -v tmux`` probe by injecting a recorder in place of
|
||||
``subprocess.run``. No real subprocess or SSH is ever spawned.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from typing import List, Optional, Sequence
|
||||
|
||||
import pytest
|
||||
from sessions.terminal_tmux_session import (
|
||||
DEFAULT_TERMINAL_CLOSE_DEFAULT,
|
||||
SESSION_NAME_PREFIX,
|
||||
TERMINAL_CLOSE_DEFAULT_VALUES,
|
||||
TERMINAL_CLOSE_KINDS,
|
||||
TerminalCloseOutcome,
|
||||
TerminalTmuxSessionError,
|
||||
TmuxProbeResult,
|
||||
build_remote_tmux_invocation,
|
||||
close_terminal_session,
|
||||
kill_terminal_session,
|
||||
list_all_remote_tmux_sessions,
|
||||
list_terminal_sessions,
|
||||
next_terminal_session_name,
|
||||
normalize_terminal_close_default,
|
||||
probe_tmux_available,
|
||||
session_name_for_host,
|
||||
)
|
||||
|
||||
# --- session_name_for_host ---------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"alias",
|
||||
[
|
||||
"prod",
|
||||
"bastion.example.com",
|
||||
"host_01",
|
||||
"worker-02",
|
||||
"CamelCase",
|
||||
"h.o.s.t",
|
||||
],
|
||||
)
|
||||
def test_session_name_for_host_accepts_safe_aliases(alias: str) -> None:
|
||||
name = session_name_for_host(alias)
|
||||
assert name == f"{SESSION_NAME_PREFIX}{alias}"
|
||||
# Prefix distinct from the agent-tmux one so Track C2 and Track D
|
||||
# sessions never collide on the remote host.
|
||||
assert name.startswith("sessions-term-")
|
||||
assert not name.startswith("sessions-agent-")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"alias",
|
||||
[
|
||||
"",
|
||||
"bad alias",
|
||||
"al;ias",
|
||||
"a$b",
|
||||
"a/b",
|
||||
"a\\b",
|
||||
"a|b",
|
||||
"a&b",
|
||||
"a'b",
|
||||
'a"b',
|
||||
"a`b",
|
||||
"한글",
|
||||
],
|
||||
)
|
||||
def test_session_name_for_host_rejects_unsafe_aliases(alias: str) -> None:
|
||||
with pytest.raises(TerminalTmuxSessionError):
|
||||
session_name_for_host(alias)
|
||||
|
||||
|
||||
def test_session_name_for_host_rejects_non_string() -> None:
|
||||
# The helper accepts only ``str``; hosts like ``None`` / ``int`` get a
|
||||
# clear error rather than an attribute error deep in the regex.
|
||||
with pytest.raises(TerminalTmuxSessionError):
|
||||
session_name_for_host(None) # type: ignore[arg-type]
|
||||
|
||||
|
||||
# --- build_remote_tmux_invocation -------------------------------------------
|
||||
|
||||
|
||||
def test_build_remote_tmux_invocation_wraps_preamble_and_shell() -> None:
|
||||
invocation = build_remote_tmux_invocation(
|
||||
session_name="sessions-term-prod",
|
||||
shell_preamble="cd '/srv/app' && (stty sane -ixon 2>/dev/null || true)",
|
||||
shell_command="exec bash -il",
|
||||
)
|
||||
# Preamble runs first so the initial cwd is correct; ``tmux
|
||||
# new-session -A`` attaches to the existing session or spawns a new
|
||||
# one with ``<shell>`` as its child process.
|
||||
assert invocation == (
|
||||
"cd '/srv/app' && (stty sane -ixon 2>/dev/null || true) && "
|
||||
"tmux new-session -A -s 'sessions-term-prod' exec bash -il"
|
||||
)
|
||||
|
||||
|
||||
def test_build_remote_tmux_invocation_uses_attach_or_spawn_flag() -> None:
|
||||
# ``-A`` is the idempotent flag: attach if running, create otherwise.
|
||||
invocation = build_remote_tmux_invocation(
|
||||
session_name="sessions-term-h",
|
||||
shell_preamble="cd /tmp",
|
||||
shell_command="exec zsh",
|
||||
)
|
||||
assert "tmux new-session -A -s 'sessions-term-h'" in invocation
|
||||
|
||||
|
||||
# --- probe_tmux_available ----------------------------------------------------
|
||||
|
||||
|
||||
class _RecordingRun:
|
||||
"""Callable stub that records ``subprocess.run`` invocations."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
returncode: int = 0,
|
||||
stdout: str = "",
|
||||
stderr: str = "",
|
||||
raises: Optional[BaseException] = None,
|
||||
) -> None:
|
||||
self.returncode = returncode
|
||||
self.stdout = stdout
|
||||
self.stderr = stderr
|
||||
self.raises = raises
|
||||
self.calls: List[Sequence[str]] = []
|
||||
|
||||
def __call__(self, argv, **_kwargs) -> subprocess.CompletedProcess:
|
||||
self.calls.append(list(argv))
|
||||
if self.raises is not None:
|
||||
raise self.raises
|
||||
return subprocess.CompletedProcess(
|
||||
args=list(argv),
|
||||
returncode=self.returncode,
|
||||
stdout=self.stdout,
|
||||
stderr=self.stderr,
|
||||
)
|
||||
|
||||
|
||||
def test_probe_tmux_available_true_when_command_v_succeeds() -> None:
|
||||
run = _RecordingRun(returncode=0, stdout="/usr/bin/tmux\n", stderr="")
|
||||
result = probe_tmux_available("prod", run=run)
|
||||
assert isinstance(result, TmuxProbeResult)
|
||||
assert result.available is True
|
||||
assert result.exit_code == 0
|
||||
assert result.stdout == "/usr/bin/tmux"
|
||||
# Built on the default ``ssh <alias>`` argv prefix; caller can
|
||||
# override via ``ssh_command_builder``.
|
||||
assert run.calls == [["ssh", "prod", "command", "-v", "tmux"]]
|
||||
|
||||
|
||||
def test_probe_tmux_available_false_when_command_v_empty_stdout() -> None:
|
||||
# POSIX shells return 0 with empty stdout for missing commands in
|
||||
# some edge cases — treat empty stdout as "missing".
|
||||
run = _RecordingRun(returncode=0, stdout="", stderr="")
|
||||
result = probe_tmux_available("prod", run=run)
|
||||
assert result.available is False
|
||||
|
||||
|
||||
def test_probe_tmux_available_false_when_command_v_exits_nonzero() -> None:
|
||||
run = _RecordingRun(returncode=1, stdout="", stderr="command not found")
|
||||
result = probe_tmux_available("prod", run=run)
|
||||
assert result.available is False
|
||||
assert result.stderr == "command not found"
|
||||
|
||||
|
||||
def test_probe_tmux_available_folds_timeout_into_false() -> None:
|
||||
run = _RecordingRun(
|
||||
raises=subprocess.TimeoutExpired(cmd=["ssh"], timeout=5.0),
|
||||
)
|
||||
result = probe_tmux_available("prod", run=run, timeout=5.0)
|
||||
assert result.available is False
|
||||
assert "timeout" in result.stderr
|
||||
|
||||
|
||||
def test_probe_tmux_available_folds_oserror_into_false() -> None:
|
||||
run = _RecordingRun(raises=OSError("ssh: not found"))
|
||||
result = probe_tmux_available("prod", run=run)
|
||||
assert result.available is False
|
||||
assert "ssh probe failed" in result.stderr
|
||||
|
||||
|
||||
def test_probe_tmux_available_uses_custom_ssh_builder() -> None:
|
||||
run = _RecordingRun(returncode=0, stdout="/usr/bin/tmux")
|
||||
|
||||
def builder(alias: str) -> List[str]:
|
||||
return ["ssh", "-F", "/tmp/config", alias]
|
||||
|
||||
probe_tmux_available("bastion", run=run, ssh_command_builder=builder)
|
||||
assert run.calls == [
|
||||
["ssh", "-F", "/tmp/config", "bastion", "command", "-v", "tmux"],
|
||||
]
|
||||
|
||||
|
||||
# --- next_terminal_session_name ----------------------------------------------
|
||||
|
||||
|
||||
def test_next_terminal_session_name_starts_at_two() -> None:
|
||||
# The base session ``sessions-term-prod`` is reserved for the
|
||||
# default reattach command; the first new pane is always ``-2``.
|
||||
assert next_terminal_session_name("prod", []) == "sessions-term-prod-2"
|
||||
|
||||
|
||||
def test_next_terminal_session_name_starts_at_two_when_only_base_running() -> None:
|
||||
# Even if the base is already up, the first numbered pane is ``-2``.
|
||||
assert (
|
||||
next_terminal_session_name("prod", ["sessions-term-prod"])
|
||||
== "sessions-term-prod-2"
|
||||
)
|
||||
|
||||
|
||||
def test_next_terminal_session_name_skips_used_indices() -> None:
|
||||
existing = [
|
||||
"sessions-term-prod",
|
||||
"sessions-term-prod-2",
|
||||
"sessions-term-prod-3",
|
||||
"sessions-term-other",
|
||||
]
|
||||
assert next_terminal_session_name("prod", existing) == "sessions-term-prod-4"
|
||||
|
||||
|
||||
def test_next_terminal_session_name_fills_gaps() -> None:
|
||||
# The smallest free index is preferred so users don't see ever-growing
|
||||
# numbers when they kill an intermediate pane.
|
||||
existing = ["sessions-term-prod-2", "sessions-term-prod-4"]
|
||||
assert next_terminal_session_name("prod", existing) == "sessions-term-prod-3"
|
||||
|
||||
|
||||
def test_next_terminal_session_name_ignores_other_hosts() -> None:
|
||||
existing = ["sessions-term-staging-2", "sessions-term-staging-3"]
|
||||
assert next_terminal_session_name("prod", existing) == "sessions-term-prod-2"
|
||||
|
||||
|
||||
def test_next_terminal_session_name_ignores_non_numeric_suffix() -> None:
|
||||
# A user-renamed session like ``sessions-term-prod-debug`` shouldn't
|
||||
# bump the next index — only purely numeric suffixes count.
|
||||
existing = [
|
||||
"sessions-term-prod-debug",
|
||||
"sessions-term-prod-2",
|
||||
]
|
||||
assert next_terminal_session_name("prod", existing) == "sessions-term-prod-3"
|
||||
|
||||
|
||||
def test_next_terminal_session_name_rejects_invalid_alias() -> None:
|
||||
with pytest.raises(TerminalTmuxSessionError):
|
||||
next_terminal_session_name("bad alias", [])
|
||||
|
||||
|
||||
# --- list_terminal_sessions --------------------------------------------------
|
||||
|
||||
|
||||
def test_list_terminal_sessions_filters_to_terminal_prefix() -> None:
|
||||
stdout = (
|
||||
"sessions-term-prod\n"
|
||||
"sessions-term-prod-2\n"
|
||||
"sessions-agent-abc12345-claude\n" # agent prefix — excluded.
|
||||
"user-shell\n" # unrelated session — excluded.
|
||||
)
|
||||
run = _RecordingRun(returncode=0, stdout=stdout)
|
||||
result = list_terminal_sessions("prod", run=run)
|
||||
assert result == ["sessions-term-prod", "sessions-term-prod-2"]
|
||||
# The remote command is passed as a SINGLE pre-quoted string so the
|
||||
# remote shell doesn't strip the quotes around ``#{session_name}``
|
||||
# and treat ``#`` as a comment marker (regression: rc=1 with
|
||||
# ``-F expects an argument`` reported in the v0.6.12 test pass).
|
||||
assert run.calls == [
|
||||
[
|
||||
"ssh",
|
||||
"prod",
|
||||
"tmux list-sessions -F '#{session_name}'",
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
def test_list_terminal_sessions_quotes_format_string_so_remote_keeps_hash() -> None:
|
||||
"""Regression for v0.6.12 test pass: the remote shell saw ``-F`` with
|
||||
no value because ``#{session_name}`` was passed as a separate argv
|
||||
entry, joined by SSH with spaces, then split by the remote shell —
|
||||
where ``#`` started a comment. The fix bundles the entire remote
|
||||
command into a single shell-quoted string. Pin both observable
|
||||
consequences (only one trailing argv entry, single-quoted ``#{...}``
|
||||
intact) so a future "tidy this up" refactor can't reintroduce the
|
||||
silent ``-F expects an argument`` failure.
|
||||
"""
|
||||
run = _RecordingRun(returncode=0, stdout="")
|
||||
list_terminal_sessions("prod", run=run)
|
||||
# SSH sees exactly TWO trailing args — the host and ONE remote command.
|
||||
assert len(run.calls) == 1
|
||||
argv = run.calls[0]
|
||||
assert argv[:2] == ["ssh", "prod"]
|
||||
assert len(argv) == 3, (
|
||||
"remote command must be a single pre-quoted string, not "
|
||||
f"separate argv entries: {argv}"
|
||||
)
|
||||
remote_cmd = argv[2]
|
||||
assert "'#{session_name}'" in remote_cmd, (
|
||||
f"single quotes around the format string are mandatory; got: {remote_cmd!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_list_terminal_sessions_returns_empty_when_no_server_running() -> None:
|
||||
# tmux exits 1 with "no server running" when nothing is up — treated
|
||||
# as empty so the caller doesn't need a try/except.
|
||||
run = _RecordingRun(returncode=1, stdout="", stderr="no server running")
|
||||
result = list_terminal_sessions("prod", run=run)
|
||||
assert result == []
|
||||
|
||||
|
||||
def test_list_terminal_sessions_returns_empty_when_tmux_missing() -> None:
|
||||
run = _RecordingRun(returncode=127, stdout="", stderr="tmux: command not found")
|
||||
result = list_terminal_sessions("prod", run=run)
|
||||
assert result == []
|
||||
|
||||
|
||||
def test_list_terminal_sessions_returns_empty_on_timeout() -> None:
|
||||
run = _RecordingRun(
|
||||
raises=subprocess.TimeoutExpired(cmd=["ssh"], timeout=5.0),
|
||||
)
|
||||
assert list_terminal_sessions("prod", run=run, timeout=5.0) == []
|
||||
|
||||
|
||||
def test_list_terminal_sessions_returns_empty_on_oserror() -> None:
|
||||
run = _RecordingRun(raises=OSError("ssh: not found"))
|
||||
assert list_terminal_sessions("prod", run=run) == []
|
||||
|
||||
|
||||
# --- list_all_remote_tmux_sessions -------------------------------------------
|
||||
|
||||
|
||||
def test_list_all_remote_tmux_sessions_returns_every_name() -> None:
|
||||
"""No SESSION_NAME_PREFIX filter — Sessions-owned, agent, foreign all kept."""
|
||||
stdout = (
|
||||
"sessions-term-prod\n"
|
||||
"sessions-term-prod-2\n"
|
||||
"sessions-agent-abc12345-claude\n" # agent — kept here, foreign-style.
|
||||
"work\n" # user-named — kept.
|
||||
"0\n" # default tmux numeric session — kept.
|
||||
)
|
||||
run = _RecordingRun(returncode=0, stdout=stdout)
|
||||
result = list_all_remote_tmux_sessions("prod", run=run)
|
||||
assert result == [
|
||||
"sessions-term-prod",
|
||||
"sessions-term-prod-2",
|
||||
"sessions-agent-abc12345-claude",
|
||||
"work",
|
||||
"0",
|
||||
]
|
||||
# Same argv shape as the filtered sibling — only the post-processing differs.
|
||||
# See the prefix-filter test for the full reasoning behind the
|
||||
# single pre-quoted remote-command string.
|
||||
assert run.calls == [
|
||||
[
|
||||
"ssh",
|
||||
"prod",
|
||||
"tmux list-sessions -F '#{session_name}'",
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
def test_list_all_remote_tmux_sessions_returns_empty_when_no_server_running() -> None:
|
||||
run = _RecordingRun(returncode=1, stdout="", stderr="no server running")
|
||||
assert list_all_remote_tmux_sessions("prod", run=run) == []
|
||||
|
||||
|
||||
def test_list_all_remote_tmux_sessions_returns_empty_when_tmux_missing() -> None:
|
||||
run = _RecordingRun(returncode=127, stdout="", stderr="tmux: command not found")
|
||||
assert list_all_remote_tmux_sessions("prod", run=run) == []
|
||||
|
||||
|
||||
def test_list_all_remote_tmux_sessions_returns_empty_on_timeout() -> None:
|
||||
run = _RecordingRun(
|
||||
raises=subprocess.TimeoutExpired(cmd=["ssh"], timeout=5.0),
|
||||
)
|
||||
assert list_all_remote_tmux_sessions("prod", run=run, timeout=5.0) == []
|
||||
|
||||
|
||||
def test_list_all_remote_tmux_sessions_returns_empty_on_oserror() -> None:
|
||||
run = _RecordingRun(raises=OSError("ssh: not found"))
|
||||
assert list_all_remote_tmux_sessions("prod", run=run) == []
|
||||
|
||||
|
||||
def test_list_all_remote_tmux_sessions_skips_blank_lines() -> None:
|
||||
"""Blank stdout lines (trailing newline, doubles) collapse out."""
|
||||
stdout = "work\n\n\nsessions-term-prod\n"
|
||||
run = _RecordingRun(returncode=0, stdout=stdout)
|
||||
assert list_all_remote_tmux_sessions("prod", run=run) == [
|
||||
"work",
|
||||
"sessions-term-prod",
|
||||
]
|
||||
|
||||
|
||||
# --- kill_terminal_session ---------------------------------------------------
|
||||
|
||||
|
||||
def test_kill_terminal_session_runs_kill_session_argv() -> None:
|
||||
run = _RecordingRun(returncode=0)
|
||||
completed = kill_terminal_session("prod", "sessions-term-prod-2", run=run)
|
||||
assert completed.returncode == 0
|
||||
assert run.calls == [
|
||||
[
|
||||
"ssh",
|
||||
"prod",
|
||||
"tmux",
|
||||
"kill-session",
|
||||
"-t",
|
||||
"sessions-term-prod-2",
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
def test_kill_terminal_session_refuses_non_terminal_session_names() -> None:
|
||||
# Hard-coded refusal so a misuse (e.g. passing an agent session name)
|
||||
# cannot accidentally tear down something the caller didn't intend.
|
||||
run = _RecordingRun(returncode=0)
|
||||
with pytest.raises(TerminalTmuxSessionError):
|
||||
kill_terminal_session("prod", "sessions-agent-abc-claude", run=run)
|
||||
assert run.calls == [] # never reached the SSH call
|
||||
|
||||
|
||||
def test_kill_terminal_session_propagates_already_gone_stderr() -> None:
|
||||
# ``kill-session`` exits non-zero when the session is gone; we
|
||||
# surface the stderr verbatim so the caller can render a hint.
|
||||
run = _RecordingRun(
|
||||
returncode=1,
|
||||
stdout="",
|
||||
stderr="can't find session: sessions-term-prod-7",
|
||||
)
|
||||
completed = kill_terminal_session("prod", "sessions-term-prod-7", run=run)
|
||||
assert completed.returncode == 1
|
||||
assert "can't find session" in completed.stderr
|
||||
|
||||
|
||||
def test_kill_terminal_session_uses_custom_ssh_builder() -> None:
|
||||
run = _RecordingRun(returncode=0)
|
||||
|
||||
def builder(alias: str) -> List[str]:
|
||||
return ["ssh", "-F", "/tmp/config", alias]
|
||||
|
||||
kill_terminal_session(
|
||||
"bastion",
|
||||
"sessions-term-bastion",
|
||||
run=run,
|
||||
ssh_command_builder=builder,
|
||||
)
|
||||
assert run.calls == [
|
||||
[
|
||||
"ssh",
|
||||
"-F",
|
||||
"/tmp/config",
|
||||
"bastion",
|
||||
"tmux",
|
||||
"kill-session",
|
||||
"-t",
|
||||
"sessions-term-bastion",
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
# --- close_terminal_session --------------------------------------------------
|
||||
|
||||
|
||||
def test_close_terminal_session_detach_is_a_noop() -> None:
|
||||
"""``"detach"`` must leave the remote tmux session untouched."""
|
||||
run = _RecordingRun(returncode=0)
|
||||
outcome = close_terminal_session(
|
||||
"prod", "sessions-term-prod", kind="detach", run=run
|
||||
)
|
||||
assert isinstance(outcome, TerminalCloseOutcome)
|
||||
assert outcome.kind == "detach"
|
||||
assert outcome.executed is False
|
||||
assert outcome.completed is None
|
||||
# No SSH call — the session is meant to persist for the next attach.
|
||||
assert run.calls == []
|
||||
|
||||
|
||||
def test_close_terminal_session_plain_runs_kill_session_argv() -> None:
|
||||
"""``"plain"`` is the default-on-pane-close non-persistent path."""
|
||||
run = _RecordingRun(returncode=0)
|
||||
outcome = close_terminal_session(
|
||||
"prod", "sessions-term-prod-2", kind="plain", run=run
|
||||
)
|
||||
assert outcome.kind == "plain"
|
||||
assert outcome.executed is True
|
||||
assert outcome.completed is not None
|
||||
assert outcome.completed.returncode == 0
|
||||
# Same SSH effect as ``"kill"`` — both go through ``kill-session``.
|
||||
assert run.calls == [
|
||||
[
|
||||
"ssh",
|
||||
"prod",
|
||||
"tmux",
|
||||
"kill-session",
|
||||
"-t",
|
||||
"sessions-term-prod-2",
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
def test_close_terminal_session_kill_runs_kill_session_argv() -> None:
|
||||
"""``"kill"`` is the explicit palette command path; same SSH effect."""
|
||||
run = _RecordingRun(returncode=0)
|
||||
outcome = close_terminal_session(
|
||||
"prod", "sessions-term-prod-2", kind="kill", run=run
|
||||
)
|
||||
assert outcome.kind == "kill"
|
||||
assert outcome.executed is True
|
||||
assert outcome.completed is not None
|
||||
assert run.calls == [
|
||||
[
|
||||
"ssh",
|
||||
"prod",
|
||||
"tmux",
|
||||
"kill-session",
|
||||
"-t",
|
||||
"sessions-term-prod-2",
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
def test_close_terminal_session_refuses_non_terminal_session_names() -> None:
|
||||
"""The session-name guard from kill_terminal_session is reused."""
|
||||
run = _RecordingRun(returncode=0)
|
||||
for kind in TERMINAL_CLOSE_KINDS:
|
||||
with pytest.raises(TerminalTmuxSessionError):
|
||||
close_terminal_session(
|
||||
"prod", "sessions-agent-abc-claude", kind=kind, run=run
|
||||
)
|
||||
# Even ``"detach"`` (a no-op SSH-wise) must reject misuse so the
|
||||
# contract is uniform across modes.
|
||||
assert run.calls == []
|
||||
|
||||
|
||||
def test_close_terminal_session_rejects_unknown_kind() -> None:
|
||||
run = _RecordingRun(returncode=0)
|
||||
with pytest.raises(TerminalTmuxSessionError):
|
||||
close_terminal_session(
|
||||
"prod",
|
||||
"sessions-term-prod",
|
||||
kind="quit", # type: ignore[arg-type]
|
||||
run=run,
|
||||
)
|
||||
assert run.calls == []
|
||||
|
||||
|
||||
def test_close_terminal_session_plain_propagates_already_gone_stderr() -> None:
|
||||
run = _RecordingRun(
|
||||
returncode=1,
|
||||
stdout="",
|
||||
stderr="can't find session: sessions-term-prod-7",
|
||||
)
|
||||
outcome = close_terminal_session(
|
||||
"prod", "sessions-term-prod-7", kind="plain", run=run
|
||||
)
|
||||
assert outcome.executed is True
|
||||
assert outcome.completed is not None
|
||||
assert outcome.completed.returncode == 1
|
||||
assert "can't find session" in outcome.completed.stderr
|
||||
|
||||
|
||||
def test_close_terminal_session_passes_through_custom_ssh_builder() -> None:
|
||||
run = _RecordingRun(returncode=0)
|
||||
|
||||
def builder(alias: str) -> List[str]:
|
||||
return ["ssh", "-F", "/tmp/config", alias]
|
||||
|
||||
close_terminal_session(
|
||||
"bastion",
|
||||
"sessions-term-bastion",
|
||||
kind="plain",
|
||||
run=run,
|
||||
ssh_command_builder=builder,
|
||||
)
|
||||
assert run.calls == [
|
||||
[
|
||||
"ssh",
|
||||
"-F",
|
||||
"/tmp/config",
|
||||
"bastion",
|
||||
"tmux",
|
||||
"kill-session",
|
||||
"-t",
|
||||
"sessions-term-bastion",
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
# --- normalize_terminal_close_default ----------------------------------------
|
||||
|
||||
|
||||
def test_normalize_terminal_close_default_accepts_known_values() -> None:
|
||||
for value in TERMINAL_CLOSE_DEFAULT_VALUES:
|
||||
assert normalize_terminal_close_default(value) == value
|
||||
|
||||
|
||||
def test_normalize_terminal_close_default_trims_and_lowercases() -> None:
|
||||
assert normalize_terminal_close_default(" Plain ") == "plain"
|
||||
assert normalize_terminal_close_default("DETACH") == "detach"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw",
|
||||
[
|
||||
None,
|
||||
0,
|
||||
1,
|
||||
True,
|
||||
False,
|
||||
[],
|
||||
["plain"],
|
||||
{"mode": "plain"},
|
||||
"kill", # ``kill`` is a *runtime* close kind, not a default policy.
|
||||
"persist",
|
||||
"",
|
||||
" ",
|
||||
],
|
||||
)
|
||||
def test_normalize_terminal_close_default_falls_back_for_unknown(raw: object) -> None:
|
||||
assert normalize_terminal_close_default(raw) == DEFAULT_TERMINAL_CLOSE_DEFAULT
|
||||
|
||||
|
||||
def test_default_terminal_close_default_is_detach() -> None:
|
||||
"""Preserving v0.6.x behavior: the shipped default keeps detach semantics."""
|
||||
assert DEFAULT_TERMINAL_CLOSE_DEFAULT == "detach"
|
||||
|
||||
|
||||
def test_terminal_close_default_values_excludes_kill() -> None:
|
||||
"""``kill`` is reserved for explicit user action, never a default policy."""
|
||||
assert "kill" not in TERMINAL_CLOSE_DEFAULT_VALUES
|
||||
assert set(TERMINAL_CLOSE_DEFAULT_VALUES) == {"detach", "plain"}
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
v0.6.1 fixed the ``cmd.exe`` console flash that plagued the Windows
|
||||
test pass of v0.6.0: every ``subprocess.run`` / ``subprocess.Popen``
|
||||
call in ``agent_tmux`` / ``jupyter_hosting`` / ``terminal_tmux_session``
|
||||
now threads through ``ssh_runner._subprocess_no_window_kwargs()``.
|
||||
call in ``marimo_hosting`` now threads through
|
||||
``ssh_runner._subprocess_no_window_kwargs()``.
|
||||
|
||||
This module is the regression shield. It monkey-patches
|
||||
``sys.platform = "win32"`` so the helper returns the real Windows
|
||||
@@ -25,7 +25,7 @@ from types import SimpleNamespace
|
||||
from typing import Any, Dict, List
|
||||
from unittest.mock import patch
|
||||
|
||||
from sessions import agent_tmux, jupyter_hosting, terminal_tmux_session
|
||||
from sessions import marimo_hosting
|
||||
from sessions.ssh_runner import _subprocess_no_window_kwargs
|
||||
|
||||
# The flag value is a real constant on Windows Python, 0x08000000. On
|
||||
@@ -61,64 +61,7 @@ def test_helper_empty_on_posix(monkeypatch) -> None:
|
||||
assert _subprocess_no_window_kwargs() == {}
|
||||
|
||||
|
||||
def test_agent_tmux_is_running_passes_creationflags(monkeypatch) -> None:
|
||||
_install_win32(monkeypatch)
|
||||
captured: List[Dict[str, Any]] = []
|
||||
|
||||
def recorder(argv, **kwargs): # type: ignore[no-untyped-def]
|
||||
captured.append(dict(kwargs))
|
||||
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
||||
|
||||
broker = agent_tmux.AgentTmuxBroker(run=recorder)
|
||||
broker.is_running("dev", "sessions-agent-abc-claude")
|
||||
assert len(captured) == 1
|
||||
assert captured[0].get("creationflags") == _EXPECTED_WINDOWS_FLAG
|
||||
|
||||
|
||||
def test_agent_tmux_list_sessions_passes_creationflags(monkeypatch) -> None:
|
||||
_install_win32(monkeypatch)
|
||||
captured: List[Dict[str, Any]] = []
|
||||
|
||||
def recorder(argv, **kwargs): # type: ignore[no-untyped-def]
|
||||
captured.append(dict(kwargs))
|
||||
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
||||
|
||||
broker = agent_tmux.AgentTmuxBroker(run=recorder)
|
||||
broker.list_sessions("dev")
|
||||
assert captured[0].get("creationflags") == _EXPECTED_WINDOWS_FLAG
|
||||
|
||||
|
||||
def test_agent_tmux_kill_passes_creationflags(monkeypatch) -> None:
|
||||
_install_win32(monkeypatch)
|
||||
captured: List[Dict[str, Any]] = []
|
||||
|
||||
def recorder(argv, **kwargs): # type: ignore[no-untyped-def]
|
||||
captured.append(dict(kwargs))
|
||||
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
||||
|
||||
broker = agent_tmux.AgentTmuxBroker(run=recorder)
|
||||
broker.kill("dev", "sessions-agent-abc-claude")
|
||||
assert captured[0].get("creationflags") == _EXPECTED_WINDOWS_FLAG
|
||||
|
||||
|
||||
def test_agent_tmux_spawn_passes_creationflags(monkeypatch) -> None:
|
||||
_install_win32(monkeypatch)
|
||||
captured: List[Dict[str, Any]] = []
|
||||
|
||||
def recorder(argv, **kwargs): # type: ignore[no-untyped-def]
|
||||
captured.append(dict(kwargs))
|
||||
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
||||
|
||||
broker = agent_tmux.AgentTmuxBroker(run=recorder)
|
||||
plan = broker.plan("dev", "cache-xyz", "claude", ("claude",))
|
||||
# Patch is_running to False so attach_or_spawn takes the spawn branch.
|
||||
monkeypatch.setattr(broker, "is_running", lambda *_a, **_kw: False)
|
||||
broker.attach_or_spawn(plan)
|
||||
# Two captured calls: is_running is patched out, so just the spawn.
|
||||
assert any(c.get("creationflags") == _EXPECTED_WINDOWS_FLAG for c in captured)
|
||||
|
||||
|
||||
def test_jupyter_default_run_merges_creationflags(monkeypatch) -> None:
|
||||
def test_marimo_default_run_merges_creationflags(monkeypatch) -> None:
|
||||
_install_win32(monkeypatch)
|
||||
captured: List[Dict[str, Any]] = []
|
||||
|
||||
@@ -127,12 +70,12 @@ def test_jupyter_default_run_merges_creationflags(monkeypatch) -> None:
|
||||
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", fake_subprocess_run)
|
||||
jupyter_hosting._default_run(["echo", "hi"], check=False)
|
||||
marimo_hosting._default_run(["echo", "hi"], check=False)
|
||||
assert captured[0].get("creationflags") == _EXPECTED_WINDOWS_FLAG
|
||||
assert captured[0].get("check") is False # caller kwargs preserved
|
||||
|
||||
|
||||
def test_jupyter_default_popen_merges_creationflags(monkeypatch) -> None:
|
||||
def test_marimo_default_popen_merges_creationflags(monkeypatch) -> None:
|
||||
_install_win32(monkeypatch)
|
||||
captured: List[Dict[str, Any]] = []
|
||||
|
||||
@@ -144,42 +87,10 @@ def test_jupyter_default_popen_merges_creationflags(monkeypatch) -> None:
|
||||
return _DummyProc()
|
||||
|
||||
monkeypatch.setattr(subprocess, "Popen", fake_subprocess_popen)
|
||||
jupyter_hosting._default_popen(["echo", "hi"])
|
||||
marimo_hosting._default_popen(["echo", "hi"])
|
||||
assert captured[0].get("creationflags") == _EXPECTED_WINDOWS_FLAG
|
||||
|
||||
|
||||
def test_terminus_tmux_probe_passes_creationflags(monkeypatch) -> None:
|
||||
_install_win32(monkeypatch)
|
||||
captured: List[Dict[str, Any]] = []
|
||||
|
||||
def recorder(argv, **kwargs): # type: ignore[no-untyped-def]
|
||||
captured.append(dict(kwargs))
|
||||
return SimpleNamespace(returncode=0, stdout="/usr/bin/tmux\n", stderr="")
|
||||
|
||||
terminal_tmux_session.probe_tmux_available(
|
||||
"dev",
|
||||
ssh_command_builder=lambda alias: ["ssh", alias],
|
||||
run=recorder,
|
||||
)
|
||||
assert len(captured) == 1
|
||||
assert captured[0].get("creationflags") == _EXPECTED_WINDOWS_FLAG
|
||||
|
||||
|
||||
def test_posix_branch_does_not_inject_creationflags(monkeypatch) -> None:
|
||||
# The complementary case: confirm the helper is a no-op on Linux so
|
||||
# injected subprocess.run callables don't see a surprise kwarg.
|
||||
monkeypatch.setattr(sys, "platform", "linux")
|
||||
captured: List[Dict[str, Any]] = []
|
||||
|
||||
def recorder(argv, **kwargs): # type: ignore[no-untyped-def]
|
||||
captured.append(dict(kwargs))
|
||||
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
||||
|
||||
broker = agent_tmux.AgentTmuxBroker(run=recorder)
|
||||
broker.is_running("dev", "sessions-agent-abc-claude")
|
||||
assert "creationflags" not in captured[0]
|
||||
|
||||
|
||||
# Keep the unused import below — it's one of the real-subprocess marker
|
||||
# strings the classifier greps for. Removing it drops this file out of
|
||||
# the "real-subprocess" bucket even though the test body references
|
||||
|
||||
Reference in New Issue
Block a user