Skip to content
Open
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: 3 additions & 1 deletion backend/src/switchboard/routers/pane.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@
@router.get("/pane")
def get_pane(session: str, index: int, lines: int | None = None) -> dict[str, list[str]]:
n = lines or settings.pane_capture_lines
captured = tmux.capture_pane(session, index, lines=n)
# join_wrapped: this payload paints the snapshot-mode terminal, same as
# the WS snapshot — wrapped lines must arrive whole (THI-253).
captured = tmux.capture_pane(session, index, lines=n, join_wrapped=True)
if captured is None:
raise HTTPException(status_code=404, detail="pane not found")
return {"lines": captured}
25 changes: 21 additions & 4 deletions backend/src/switchboard/services/pane_stream.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,10 @@ async def _prompt_poll_loop(self, last_sent: str | None = None) -> None:

async def run(self) -> None:
# 1. Initial snapshot via capture-pane (pipe-pane only streams NEW output).
snapshot = tmux.capture_pane(self.session, self.index, lines=500) or []
# join_wrapped so soft-wrapped lines reach xterm whole and re-wrap
# there with isWrapped set — the file-path linkifier needs that to
# make wrapped paths clickable (THI-253).
snapshot = tmux.capture_pane(self.session, self.index, lines=500, join_wrapped=True) or []
if snapshot:
try:
await self.ws.send_text("\r\n".join(snapshot) + "\r\n")
Expand Down Expand Up @@ -204,6 +207,8 @@ async def run(self) -> None:
return

fd = -1
file_obj = None
transport: asyncio.ReadTransport | None = None
pipe_active = False
prompt_task: asyncio.Task[None] | None = None
try:
Expand Down Expand Up @@ -232,7 +237,9 @@ async def run(self) -> None:
# connect_read_pipe takes ownership of the fd via the file object.
file_obj = os.fdopen(fd, "rb", buffering=0)
fd = -1 # ownership transferred
await loop.connect_read_pipe(lambda: asyncio.StreamReaderProtocol(reader), file_obj)
transport, _ = await loop.connect_read_pipe(
lambda: asyncio.StreamReaderProtocol(reader), file_obj
)

# Buffer for partial `ESC k …` sequences that span FIFO reads.
title_pending = b""
Expand Down Expand Up @@ -261,8 +268,18 @@ async def run(self) -> None:
if pipe_active:
with contextlib.suppress(Exception):
srv.cmd("pipe-pane", "-t", target) # ty: ignore
# Close fd if we still own it.
if fd >= 0:
# Release the fifo read end. The transport owns the file object
# (which owns the fd) once connect_read_pipe succeeds; before
# that, whichever of file_obj/fd we still hold. Leaking this fd
# starved the worker at macOS's 256 soft limit — one fd per pane
# WS open, ~230 opens to a dead server (THI-254).
if transport is not None:
with contextlib.suppress(Exception):
transport.close()
elif file_obj is not None:
with contextlib.suppress(Exception):
file_obj.close()
elif fd >= 0:
with contextlib.suppress(OSError):
os.close(fd)
with contextlib.suppress(OSError):
Expand Down
16 changes: 14 additions & 2 deletions backend/src/switchboard/services/tmux.py
Original file line number Diff line number Diff line change
Expand Up @@ -372,20 +372,32 @@ def pane_kind(session: str, index: int) -> Kind | None:
return _infer_kind(win.active_pane.pane_current_command or "", win.window_name or "")


def capture_pane(session: str, index: int, lines: int = 200) -> list[str] | None:
def capture_pane(
session: str, index: int, lines: int = 200, *, join_wrapped: bool = False
) -> list[str] | None:
"""Capture recent scrollback *with* ANSI escapes (`-e`).

Used by `GET /api/pane` and by pane_stream for the WebSocket's initial
paint — both need color so the terminal modal isn't monochrome until new
output streams in. `collect_state` keeps a plain (escape-free) capture for
the parser + card preview.

`join_wrapped` adds `-J`: tmux re-joins soft-wrapped lines so they reach
xterm as single logical lines, letting xterm wrap them itself and mark
`isWrapped` — which the frontend's file-path linkifier needs to make
wrapped paths clickable (THI-253). Parser-facing callers (prompt poll,
rename, search) must NOT set it: they expect screen rows as displayed,
and `-J` also preserves trailing spaces.
"""
pane = get_pane(session, index)
if pane is None:
return None
try:
# libtmux's Pane.capture_pane() can't pass -e; call tmux directly.
out = pane.cmd("capture-pane", "-p", "-e", "-S", f"-{lines}")
args = ["capture-pane", "-p", "-e"]
if join_wrapped:
args.append("-J")
out = pane.cmd(*args, "-S", f"-{lines}")
return list(out.stdout or [])
except Exception: # noqa: BLE001
return None
Expand Down
16 changes: 16 additions & 0 deletions backend/tests/test_open.py
Original file line number Diff line number Diff line change
Expand Up @@ -344,3 +344,19 @@ def __init__(self, args, **_kwargs):
r = client.post("/api/open?session=dev&index=0&path=x.py", headers=_csrf(client))
assert r.status_code == 200
assert captured["args"][0] == "code" # settings.ide_cmd default


def test_api_pane_requests_joined_wrapped_lines(monkeypatch) -> None:
"""THI-253: GET /api/pane feeds the snapshot-mode terminal paint, so it
must also capture with join_wrapped=True."""
from switchboard.routers import pane as pane_router

recorded: list[dict] = []

def fake_capture(session: str, index: int, **kwargs):
recorded.append(kwargs)
return ["x"]

monkeypatch.setattr(pane_router.tmux, "capture_pane", fake_capture)
assert pane_router.get_pane("s", 0, lines=5) == {"lines": ["x"]}
assert recorded[0].get("join_wrapped") is True
60 changes: 60 additions & 0 deletions backend/tests/test_pane_stream.py
Original file line number Diff line number Diff line change
Expand Up @@ -239,3 +239,63 @@ def test_strip_screen_titles_strips_multiple_in_one_chunk() -> None:
clean, pending = pane_stream._strip_screen_titles(b"\x1bkecho\x1b\\\x1bkls\x1b\\ok", b"")
assert clean == b"ok"
assert pending == b""


def test_run_snapshot_requests_joined_wrapped_lines(monkeypatch) -> None:
"""THI-253: the WS initial snapshot must capture with join_wrapped=True so
soft-wrapped paths reach xterm as single logical lines. get_server() is
stubbed to None so run() exits right after the snapshot send."""

async def _run() -> None:
recorded: list[dict] = []

def fake_capture(session: str, index: int, **kwargs):
recorded.append(kwargs)
return ["snapshot line"]

monkeypatch.setattr(pane_stream.tmux, "capture_pane", fake_capture)
monkeypatch.setattr(pane_stream.tmux, "get_server", lambda: None)
ws = _FakeWS()
streamer = PaneStreamer(session="s", index=0, ws=ws)
await streamer.run()
assert recorded[0].get("join_wrapped") is True
assert ws.sent == ["snapshot line\r\n"]

asyncio.run(_run())


def test_run_closes_fifo_read_end_on_cancel(monkeypatch) -> None:
"""THI-254: the fifo read-end is owned by the connect_read_pipe transport;
cancelling the streamer (WS disconnect) must close it, else every pane
WebSocket leaks one fd until the worker starves at macOS's 256 soft limit."""
import contextlib
from types import SimpleNamespace

async def _run() -> None:
captured: list = []
real_fdopen = os.fdopen

def spying_fdopen(fd: int, *args, **kwargs):
obj = real_fdopen(fd, *args, **kwargs)
captured.append(obj)
return obj

monkeypatch.setattr(pane_stream.tmux, "capture_pane", lambda *a, **k: [])
monkeypatch.setattr(
pane_stream.tmux, "get_server", lambda: SimpleNamespace(cmd=lambda *a: None)
)
monkeypatch.setattr(pane_stream.tmux, "pane_kind", lambda s, i: "shell")
monkeypatch.setattr(pane_stream.os, "fdopen", spying_fdopen)

streamer = PaneStreamer(session="s", index=0, ws=_FakeWS())
task = asyncio.create_task(streamer.run())
await asyncio.sleep(0.3) # reach the fifo read loop
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
await asyncio.sleep(0.05) # let transport-close callbacks run

assert captured, "streamer never opened the fifo read end"
assert captured[0].closed, "fifo read-end fd leaked after cancel"

asyncio.run(_run())
29 changes: 29 additions & 0 deletions backend/tests/test_tmux.py
Original file line number Diff line number Diff line change
Expand Up @@ -587,3 +587,32 @@ def test_collect_state_recaptures_pane_after_ttl_expiry(monkeypatch) -> None:
tmux.collect_state()

assert pane.capture_calls == 2


# THI-253: snapshot captures must be able to join soft-wrapped lines (`-J`)
# so xterm re-wraps them itself and marks `isWrapped` — the frontend's
# wrapped-path linkifier keys on that flag.
class _FlagRecordingPane:
def __init__(self) -> None:
self.cmd_args: list[tuple[str, ...]] = []

def cmd(self, *args: str):
self.cmd_args.append(args)
return SimpleNamespace(stdout=["line"])


def test_capture_pane_passes_join_wrapped_flag(monkeypatch) -> None:
pane = _FlagRecordingPane()
monkeypatch.setattr(tmux, "get_pane", lambda s, i: pane)
out = tmux.capture_pane("s", 0, lines=100, join_wrapped=True)
assert out == ["line"]
assert "-J" in pane.cmd_args[0]


def test_capture_pane_omits_join_flag_by_default(monkeypatch) -> None:
"""Parser-facing callers (prompt poll, rename, search) need screen rows
exactly as displayed — joining must stay opt-in."""
pane = _FlagRecordingPane()
monkeypatch.setattr(tmux, "get_pane", lambda s, i: pane)
tmux.capture_pane("s", 0, lines=100)
assert "-J" not in pane.cmd_args[0]
1 change: 1 addition & 0 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1009,6 +1009,7 @@ export function App() {
sessions={orderedSessions}
onFocus={handleFocus}
onNewWindow={setNewWindowSession}
onToast={messageToast}
/>
) : settings.layout === "list" ? (
<ListView
Expand Down
Loading
Loading