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:
2026-04-27 11:40:32 +09:00
parent 147f4bd091
commit 4e8180489a
38 changed files with 448 additions and 10635 deletions

View File

@@ -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
}

View File

@@ -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"
}
]

View File

@@ -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",
]

View File

@@ -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

View File

@@ -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",
)

View File

@@ -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
)

View File

@@ -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,
)

View File

@@ -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",
)

View File

@@ -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,
)

View File

@@ -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.
# ---------------------------------------------------------------------------

View File

@@ -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

View File

@@ -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 3060s. 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(

View File

@@ -132,7 +132,6 @@ class RemoteCacheMirrorResult:
MIRROR_BUILTIN_IGNORE_PATTERNS: Tuple[str, ...] = (
".git",
"node_modules",
"__pycache__",
".venv",

View File

@@ -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",
)

View File

@@ -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",
)

View File

@@ -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()

View File

@@ -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

View File

@@ -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

View File

@@ -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 == []

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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(

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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]

View File

@@ -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",
[

View File

@@ -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)

View File

@@ -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"
)

View File

@@ -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",

View File

@@ -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:

View File

@@ -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

View File

@@ -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",
)

View File

@@ -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"}

View File

@@ -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