Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ The default Python-buffer mappings use `maplocalleader` (a backslash unless you

Commands include `:EuporieStart`, `:EuporieStop`, `:EuporieRestart`, `:EuporieFocus`, `:EuporieStatus`, `:EuporieInterrupt`, `:EuporieSendCell`, `:EuporieRunCell`, `:[range]EuporieSend`, and `:EuporieSendFile`.

The Euporie pane is a real interactive console: focus it with `\ef`, type exploratory code directly, inspect and scroll output, use completions, or work with widgets. Input sent from Vim and input typed into Euporie share the same kernel. When the final attached Vim exits, the pane and kernel close automatically after a short grace period.
The Euporie pane is a real interactive console: focus it with `\ef`, type exploratory code directly, inspect and scroll output, use completions, or work with widgets. Input sent from Vim and input typed into Euporie share the same kernel. When the final attached Vim exits, the pane and kernel close automatically. Set a positive idle timeout if you want a grace period for reopening Vim.

## Configuration

Expand All @@ -122,7 +122,7 @@ let g:vim_euporie_auto_start = 1
let g:vim_euporie_graphics = 'kitty-unicode'
let g:vim_euporie_split = 'horizontal'
let g:vim_euporie_pane_percent = 40
let g:vim_euporie_idle_timeout = 20
let g:vim_euporie_idle_timeout = 0
```

Set `g:vim_euporie_split = 'vertical'` for a pane below Vim. To keep only `<Plug>` mappings and define your own:
Expand Down
15 changes: 10 additions & 5 deletions autoload/euporie.vim
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,9 @@ function! s:uv_command(ctx) abort
\ 'python', s:plugin_root . '/python/vim_euporie_sidecar.py',
\ '--state-file', a:ctx.state,
\ '--root', a:ctx.root,
\ '--idle-timeout', string(s:setting('idle_timeout', 20)),
\ '--owner-client', a:ctx.client,
\ '--owner-pid', string(getpid()),
\ '--idle-timeout', string(s:setting('idle_timeout', 0)),
\ '--graphics', s:setting('graphics', 'kitty-unicode'),
\ '--euporie-args-json', json_encode(s:setting('euporie_args', [])),
\ ])
Expand Down Expand Up @@ -229,6 +231,9 @@ function! euporie#start() abort
return 0
endif
let ctx.pane = pane
" The sidecar receives this client at launch, before its control socket is
" ready. This also lets it notice a Vim which exits during startup.
let ctx.attached = 1
call s:tmux(['select-pane', '-t', pane, '-T', 'euporie:' . fnamemodify(ctx.root, ':t')])
call s:ensure_heartbeat()
return 1
Expand Down Expand Up @@ -450,9 +455,9 @@ endfunction

function! euporie#detach_all() abort
for ctx in values(s:contexts)
if ctx.attached
call s:request(ctx, {'action': 'detach', 'client': ctx.client}, 1)
let ctx.attached = 0
endif
" Always attempt the detach. A context can be registered by the sidecar's
" launch arguments before Vim has observed the state file.
call s:request(ctx, {'action': 'detach', 'client': ctx.client}, 1)
let ctx.attached = 0
endfor
endfunction
5 changes: 3 additions & 2 deletions doc/euporie.txt
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ g:vim_euporie_pane_percent Default: 40
g:vim_euporie_split "horizontal" or "vertical"
g:vim_euporie_graphics Default: "kitty-unicode"
g:vim_euporie_configure_tmux Enable allow-passthrough; default: 1
g:vim_euporie_idle_timeout Seconds after final Vim detaches; default: 20
g:vim_euporie_idle_timeout Seconds after final Vim detaches; default: 0
g:vim_euporie_start_timeout First-start timeout; default: 90
g:vim_euporie_with uv overlay packages; default:
["euporie", "ipykernel"]
Expand All @@ -57,7 +57,8 @@ Otherwise the nearest pyproject.toml, uv.lock, or .git directory is used.

Each tmux window and project root shares one pane and kernel. Clients send
heartbeats, so the pane survives a handoff between Vim instances and is
removed automatically after the final client exits.
removed automatically after the final client exits. Set a positive
g:vim_euporie_idle_timeout to keep it alive briefly during a handoff.

Python files containing a PEP 723 "# /// script" metadata block use the
dependencies declared in that block and receive a separate pane and kernel.
Expand Down
64 changes: 64 additions & 0 deletions python/vim_euporie_console.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
#!/usr/bin/env python3
"""Launch Euporie without terminal probes which tmux can route to Vim."""

from __future__ import annotations

import sys


PASSTHROUGH_QUERY_METHODS = (
# Euporie 2.x
"get_colors",
"get_kitty_graphics_status",
"get_device_attributes",
"get_iterm_graphics_status",
# Euporie 3.x / apptk
"ask_for_colors",
"ask_for_kitty_graphics_status",
"ask_for_device_attributes",
"ask_for_iterm_graphics_status",
)


def suppress_passthrough_queries() -> None:
"""Disable only startup queries which escape an inactive tmux pane.

Euporie's actual Kitty graphics transmissions still use tmux passthrough.
The other capability probes remain enabled because tmux answers those
itself instead of forwarding them to Ghostty's currently active pane.
"""
try:
from apptk.output.vt100 import Vt100_Output
except ModuleNotFoundError:
from euporie.core.io import Vt100_Output

def ignore_query(_output: Vt100_Output) -> None:
return None

for method_name in PASSTHROUGH_QUERY_METHODS:
if hasattr(Vt100_Output, method_name):
setattr(Vt100_Output, method_name, ignore_query)


def main() -> None:
sys.argv[0] = "euporie-console"
from euporie.core import __main__ as euporie_main

if hasattr(euporie_main, "available_apps"):
# Euporie 2.x must install its prompt_toolkit layout patches before the
# output module is imported.
from euporie.core.layout import containers as _containers # noqa: F401

suppress_passthrough_queries()
euporie_main.main("console")
else:
# Euporie 3.x moved the output layer to apptk and no longer uses the
# entry-point discovery launcher.
from euporie.console.app import ConsoleApp

suppress_passthrough_queries()
ConsoleApp.launch()


if __name__ == "__main__":
main()
62 changes: 54 additions & 8 deletions python/vim_euporie_sidecar.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,12 @@ def __init__(self, args: argparse.Namespace) -> None:
self.kernel_lock = threading.Lock()
self.kernel_manager: Any = None
self.kernel_client: Any = None
self.kernel_log: Any = None
self.transport_encryption = "disabled"
self.console: subprocess.Popen[bytes] | None = None
self.server: socketserver.TCPServer | None = None
self.no_clients_since = time.monotonic()
self.touch_client(args.owner_client, args.owner_pid)

def touch_client(self, client: str, pid: int) -> None:
if not client:
Expand All @@ -66,7 +69,7 @@ def prune_clients(self) -> int:
self.clients = {
client: (seen, pid)
for client, (seen, pid) in self.clients.items()
if seen >= cutoff or process_is_alive(pid)
if (process_is_alive(pid) if pid > 0 else seen >= cutoff)
}
if not self.clients and not self.no_clients_since:
self.no_clients_since = time.monotonic()
Expand Down Expand Up @@ -171,6 +174,7 @@ def start_kernel(runtime: Runtime, connection_file: Path) -> None:
from jupyter_client import KernelManager

manager = KernelManager(kernel_name="python3", connection_file=str(connection_file))
runtime.kernel_manager = manager
# Force the Python kernel to use the interpreter selected by `uv run`. This
# is what makes project dependencies importable without installing a global
# kernelspec for every uv environment.
Expand All @@ -181,12 +185,44 @@ def start_kernel(runtime: Runtime, connection_file: Path) -> None:
"-f",
"{connection_file}",
]
manager.start_kernel(cwd=str(runtime.args.root))
client = manager.client()

# jupyter_client 8.9 can provision CurveZMQ keys locally when the kernel
# advertises support. This avoids the ipykernel plaintext-TCP warning and
# protects all kernel traffic, even though it remains bound to localhost.
encrypted = False
try:
import zmq

if hasattr(manager, "transport_encryption") and zmq.has("curve"):
manager.transport_encryption = "required"
manager.kernel_spec.metadata = {
**(manager.kernel_spec.metadata or {}),
"supported_encryption": ["curve"],
}
encrypted = True
except Exception:
LOG.exception("CurveZMQ setup failed; falling back to localhost transport")

kernel_log_path = runtime.args.state_file.with_suffix(".kernel.log")
runtime.kernel_log = kernel_log_path.open("ab", buffering=0)
manager.start_kernel(
cwd=str(runtime.args.root),
stdout=runtime.kernel_log,
stderr=subprocess.STDOUT,
)
client_options = {}
if encrypted:
# jupyter_client 8.9 serializes these keys to text in manager.client(),
# while the client traits correctly require raw CurveZMQ key bytes.
client_options = {
"curve_publickey": manager.curve_publickey,
"curve_secretkey": manager.curve_secretkey,
}
client = manager.client(**client_options)
runtime.kernel_client = client
client.start_channels()
client.wait_for_ready(timeout=runtime.args.kernel_timeout)
runtime.kernel_manager = manager
runtime.kernel_client = client
runtime.transport_encryption = "curve" if encrypted else "disabled"
# Match notebook behavior for `plt.show()` when matplotlib is part of the
# uv project. A missing matplotlib is deliberately ignored.
client.execute(
Expand All @@ -201,7 +237,8 @@ def start_kernel(runtime: Runtime, connection_file: Path) -> None:

def euporie_command(runtime: Runtime, connection_file: Path) -> list[str]:
command = [
"euporie-console",
sys.executable,
str(Path(__file__).with_name("vim_euporie_console.py")),
"--connection-file",
str(connection_file),
"--show-remote-inputs",
Expand All @@ -212,6 +249,8 @@ def euporie_command(runtime: Runtime, connection_file: Path) -> list[str]:
"--multiplexer-passthrough",
"--color-scheme",
"dark",
"--color-depth",
"24",
]
command.extend(json.loads(runtime.args.euporie_args_json))
return command
Expand All @@ -221,7 +260,9 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--state-file", required=True, type=Path)
parser.add_argument("--root", required=True, type=Path)
parser.add_argument("--idle-timeout", type=float, default=20.0)
parser.add_argument("--owner-client", default="")
parser.add_argument("--owner-pid", type=int, default=0)
parser.add_argument("--idle-timeout", type=float, default=0.0)
parser.add_argument("--client-timeout", type=float, default=45.0)
parser.add_argument("--kernel-timeout", type=float, default=60.0)
parser.add_argument("--graphics", default="kitty-unicode")
Expand Down Expand Up @@ -259,7 +300,9 @@ def request_stop(_signum: int, _frame: Any) -> None:
server_thread.start()

runtime.console = subprocess.Popen(
euporie_command(runtime, connection_file), cwd=args.root
euporie_command(runtime, connection_file),
cwd=args.root,
stderr=runtime.kernel_log,
)
# Give Euporie time to subscribe to IOPub before Vim can send the first
# cell, otherwise very fast first executions can be invisible.
Expand All @@ -278,6 +321,7 @@ def request_stop(_signum: int, _frame: Any) -> None:
"kernel_pid": runtime.kernel_manager.provisioner.pid,
"root": str(args.root),
"connection_file": str(connection_file),
"transport_encryption": runtime.transport_encryption,
},
)

Expand Down Expand Up @@ -311,6 +355,8 @@ def request_stop(_signum: int, _frame: Any) -> None:
runtime.kernel_client.stop_channels()
if runtime.kernel_manager is not None and runtime.kernel_manager.has_kernel:
runtime.kernel_manager.shutdown_kernel(now=True)
if runtime.kernel_log is not None:
runtime.kernel_log.close()
state_file.unlink(missing_ok=True)
connection_file.unlink(missing_ok=True)

Expand Down
67 changes: 67 additions & 0 deletions tests/test_sidecar.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import importlib.util
from pathlib import Path
from types import SimpleNamespace
import unittest


Expand All @@ -9,6 +10,14 @@
SIDECAR = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(SIDECAR)

CONSOLE_PATH = Path(__file__).parents[1] / "python" / "vim_euporie_console.py"
CONSOLE_SPEC = importlib.util.spec_from_file_location(
"vim_euporie_console", CONSOLE_PATH
)
assert CONSOLE_SPEC and CONSOLE_SPEC.loader
CONSOLE = importlib.util.module_from_spec(CONSOLE_SPEC)
CONSOLE_SPEC.loader.exec_module(CONSOLE)


class PrepareCodeTests(unittest.TestCase):
def test_code_is_unchanged(self):
Expand All @@ -21,5 +30,63 @@ def test_markdown_becomes_rich_display(self):
self.assertIn("'# Title\\n$x^2$'", rendered)


class LifecycleTests(unittest.TestCase):
def test_new_owner_is_registered_before_control_socket_starts(self):
runtime = SIDECAR.Runtime(
SimpleNamespace(
owner_client="vim-123-project",
owner_pid=123,
client_timeout=45.0,
)
)
self.assertIn("vim-123-project", runtime.clients)
self.assertEqual(0.0, runtime.no_clients_since)

def test_idle_timeout_defaults_to_immediate_cleanup(self):
args = SIDECAR.parse_args(["--state-file", "state", "--root", "."])
self.assertEqual(0.0, args.idle_timeout)

def test_dead_owner_is_pruned_without_waiting_for_heartbeat_timeout(self):
runtime = SIDECAR.Runtime(
SimpleNamespace(
owner_client="vim-dead-project",
owner_pid=2_147_483_647,
client_timeout=45.0,
)
)
self.assertEqual(0, runtime.prune_clients())
self.assertGreater(runtime.no_clients_since, 0.0)


class ConsoleLaunchTests(unittest.TestCase):
def test_only_passthrough_startup_queries_are_suppressed(self):
self.assertEqual(
{
"get_colors",
"get_kitty_graphics_status",
"get_device_attributes",
"get_iterm_graphics_status",
"ask_for_colors",
"ask_for_kitty_graphics_status",
"ask_for_device_attributes",
"ask_for_iterm_graphics_status",
},
set(CONSOLE.PASSTHROUGH_QUERY_METHODS),
)

def test_sidecar_launches_the_guarded_console_with_fixed_color_depth(self):
runtime = SimpleNamespace(
args=SimpleNamespace(
graphics="kitty-unicode",
euporie_args_json="[]",
)
)
command = SIDECAR.euporie_command(runtime, Path("kernel.json"))
self.assertEqual(SIDECAR.sys.executable, command[0])
self.assertTrue(command[1].endswith("vim_euporie_console.py"))
self.assertIn("--multiplexer-passthrough", command)
self.assertEqual("24", command[command.index("--color-depth") + 1])


if __name__ == "__main__":
unittest.main()
Loading