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
56 changes: 56 additions & 0 deletions cmux-linux/src/socket.zig
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,7 @@ const methods = .{
.{ "surface.clear_history", handleSurfaceClearHistory },
.{ "surface.action", handleSurfaceAction },
.{ "tab.action", handleSurfaceAction },
.{ "surface.report_tty", handleSurfaceReportTty },
.{ "pane.list", handlePaneList },
.{ "pane.focus", handlePaneFocus },
.{ "pane.create", handlePaneCreate },
Expand Down Expand Up @@ -1631,6 +1632,61 @@ fn handleSurfaceAction(alloc: Allocator, params: json.Value) []const u8 {
return buf.toOwnedSlice(alloc) catch "{\"error\":\"unsupported action\"}";
}

fn handleSurfaceReportTty(alloc: Allocator, params: json.Value) []const u8 {
const tm = getTabManager() orelse return "{\"error\":\"no tab manager\"}";

// Resolve workspace + target surface using the same pattern as
// handleSurfaceSendText so callers can omit either or both ids and
// fall back to the currently selected workspace + focused surface.
const workspace_found = if (getParamString(params, "workspace_id")) |id_str|
findWorkspaceById(tm, id_str) orelse return "{\"error\":\"invalid workspace_id\"}"
else
null;

const ws = if (workspace_found) |found|
found.ws
else if (getParamString(params, "surface_id")) |id_str|
if (findSurfaceGlobal(tm, id_str)) |found| found.ws else return "{\"error\":\"invalid surface_id\"}"
else
tm.selectedWorkspace() orelse return "{\"error\":\"no workspace\"}";

const target_id = if (getParamString(params, "surface_id")) |id_str|
if (workspace_found != null)
findSurfaceInWorkspace(ws, id_str) orelse return "{\"error\":\"invalid surface_id\"}"
else if (findSurfaceGlobal(tm, id_str)) |found|
found.id
else
return "{\"error\":\"invalid surface_id\"}"
else
ws.focused_panel_id orelse return "{\"error\":\"no focused surface\"}";

const tty_name_raw = getParamString(params, "tty_name") orelse return "{\"error\":\"missing tty_name\"}";
const tty_name = std.mem.trim(u8, tty_name_raw, " \t\r\n");
if (tty_name.len == 0) return "{\"error\":\"missing tty_name\"}";
Comment thread
greptile-apps[bot] marked this conversation as resolved.

const panel_ptr = ws.panels.getPtr(target_id) orelse return "{\"error\":\"invalid surface_id\"}";

// Allocate the new value first, then free the old one so a failed
// allocation never leaves the panel pointing at freed memory.
const new_tty = ws.alloc.dupe(u8, tty_name) catch return "{\"error\":\"out of memory\"}";
if (panel_ptr.*.tty_name) |old| ws.alloc.free(old);
panel_ptr.*.tty_name = new_tty;

const ws_hex = formatId(ws.id);
const panel_hex = formatId(panel_ptr.*.id);

var buf = std.ArrayList(u8).empty;
defer buf.deinit(alloc);
Comment thread
greptile-apps[bot] marked this conversation as resolved.
const writer = buf.writer(alloc);
writer.print(
"{{\"workspace_id\":\"{s}\",\"surface_id\":\"{s}\",\"tty_name\":",
.{ @as([]const u8, &ws_hex), @as([]const u8, &panel_hex) },
) catch return "{\"error\":\"encode failed\"}";
writeJsonString(writer, tty_name) catch return "{\"error\":\"encode failed\"}";
writer.writeByte('}') catch return "{\"error\":\"encode failed\"}";
return buf.toOwnedSlice(alloc) catch "{}";
}

// ── Batch 3: Additional Pane Operations ─────────────────────────────────

fn handlePaneFocus(_: Allocator, params: json.Value) []const u8 {
Expand Down
147 changes: 147 additions & 0 deletions tests_v2/test_surface_report_tty.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
#!/usr/bin/env python3
"""Socket API: surface.report_tty registers a TTY name on a panel.

Linux's report_tty is a metadata-only mutation: there is no PortScanner /
remote workspace plumbing on Linux yet, so the handler simply duplicates
the supplied tty_name onto Panel.tty_name and echoes it back.

This test verifies:
1. report_tty without surface_id falls back to the focused surface
2. report_tty with explicit surface_id targets that surface
3. tty_name with surrounding whitespace is trimmed
4. missing / empty / whitespace-only tty_name -> error
5. invalid workspace_id / surface_id -> error
6. response shape: {workspace_id, surface_id, tty_name}
"""

import os
import re
import sys
import time
from pathlib import Path

sys.path.insert(0, str(Path(__file__).parent))
from cmux import cmux, cmuxError

SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock")

UUID_HEX_RE = re.compile(r"^[0-9a-f]{32}$")


def _assert_uuid(label: str, value: object) -> None:
if not isinstance(value, str) or not UUID_HEX_RE.match(value):
raise cmuxError(f"{label} not a 32-char hex UUID: {value!r}")


def _expect_error(label: str, fn) -> None:
try:
fn()
except cmuxError:
return
raise cmuxError(f"{label}: expected error, call returned successfully")


def main() -> int:
with cmux(SOCKET_PATH) as c:
ws_id = c.new_workspace()
c.select_workspace(ws_id)
time.sleep(0.1)

surfaces = c.list_surfaces()
if not surfaces:
raise cmuxError("Expected at least one surface in fresh workspace")
focused = next((s for s in surfaces if s[2]), None)
if focused is None:
raise cmuxError("No focused surface in fresh workspace")
focused_id = focused[1]
_assert_uuid("focused surface_id", focused_id)

# 1. No surface_id — defaults to focused surface
res1 = c._call(
"surface.report_tty",
{"workspace_id": ws_id, "tty_name": "/dev/pts/42"},
)
if not isinstance(res1, dict):
raise cmuxError(f"report_tty (default surface) returned {res1!r}")
if res1.get("workspace_id") != ws_id:
raise cmuxError(
f"workspace_id echo mismatch: {res1.get('workspace_id')!r} vs {ws_id!r}"
)
if res1.get("surface_id") != focused_id:
raise cmuxError(
f"default surface_id should equal focused {focused_id!r}, got {res1.get('surface_id')!r}"
)
if res1.get("tty_name") != "/dev/pts/42":
Comment thread
greptile-apps[bot] marked this conversation as resolved.
raise cmuxError(f"tty_name echo mismatch: {res1.get('tty_name')!r}")

# 2. Explicit surface_id targets that surface (here it's the same one)
res2 = c._call(
"surface.report_tty",
{
"workspace_id": ws_id,
"surface_id": focused_id,
"tty_name": "/dev/pts/99",
},
)
if res2.get("surface_id") != focused_id:
raise cmuxError(f"explicit surface_id mismatch: {res2!r}")
if res2.get("tty_name") != "/dev/pts/99":
raise cmuxError(f"second tty_name not echoed: {res2!r}")

# 3. Surrounding whitespace gets trimmed
res3 = c._call(
"surface.report_tty",
{"workspace_id": ws_id, "tty_name": " /dev/pts/7\n"},
)
if res3.get("tty_name") != "/dev/pts/7":
raise cmuxError(f"tty_name not trimmed: {res3!r}")

# 4. Empty / whitespace-only / missing tty_name -> error
_expect_error(
"missing tty_name",
lambda: c._call("surface.report_tty", {"workspace_id": ws_id}),
)
_expect_error(
"empty tty_name",
lambda: c._call(
"surface.report_tty",
{"workspace_id": ws_id, "tty_name": ""},
),
)
_expect_error(
"whitespace-only tty_name",
lambda: c._call(
"surface.report_tty",
{"workspace_id": ws_id, "tty_name": " \t\n"},
),
)

# 5. Invalid ids -> error
_expect_error(
"invalid workspace_id",
lambda: c._call(
"surface.report_tty",
{"workspace_id": "0" * 32, "tty_name": "/dev/pts/0"},
),
)
_expect_error(
"invalid surface_id",
lambda: c._call(
"surface.report_tty",
{
"workspace_id": ws_id,
"surface_id": "0" * 32,
"tty_name": "/dev/pts/0",
},
),
)

# Cleanup
c.close_workspace(ws_id)

print("PASS: surface.report_tty (default + explicit + trim + errors)")
return 0


if __name__ == "__main__":
raise SystemExit(main())
Loading