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
27 changes: 20 additions & 7 deletions src/ccbot/local_terminal.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,10 +167,17 @@ def _build_tmux_command(window_id: str) -> str:
some macOS versions) Terminal.app's ``do script`` exec ``X``
*without* a shell wrapper, so ``\\;``, ``||``, and ``;`` lose
their shell semantics. Wrapping in ``bash -c`` makes the operator
semantics explicit. The trailing ``exec ${SHELL:-bash} -l`` keeps
the window open after the user detaches. ``tmux`` is invoked by
absolute path because iTerm's bash inherits a stripped PATH on
macOS.
semantics explicit. ``tmux`` is invoked by absolute path because
iTerm's bash inherits a stripped PATH on macOS.

The trailing ``exec ${SHELL:-bash} -l`` is *conditional*: it only
runs while the per-window group session is still alive. On a manual
detach (``Ctrl-b d``) the group survives, so the shell stays and
scrollback is preserved. On a session kill ``kill_window`` tears the
group down (see ``tmux_manager._kill_grouped_session_for_window``),
so ``has-session`` fails, the wrapper exits cleanly, and the host
terminal closes the now-orphaned tab instead of leaving it as a bare
login shell.
"""
session_name = config.tmux_session_name
session_q = shlex.quote(session_name)
Expand All @@ -179,11 +186,13 @@ def _build_tmux_command(window_id: str) -> str:
group_q = shlex.quote(group)
target_q = shlex.quote(f"{group}:{window_id}")
# Each tmux call is independent — failure of one (e.g. group already
# exists) does not abort the chain. The trailing exec always runs.
# exists) does not abort the chain. The trailing exec runs only if the
# group still exists after attach returns (detach, not kill).
inner = (
f"{tmux_bin} new-session -d -t {session_q} -s {group_q} 2>/dev/null; "
f"{tmux_bin} select-window -t {target_q} 2>/dev/null; "
f"{tmux_bin} attach-session -t {group_q}; "
f"{tmux_bin} has-session -t {group_q} 2>/dev/null && "
f"exec ${{SHELL:-bash}} -l"
)
return f"bash -c {shlex.quote(inner)}"
Expand All @@ -204,7 +213,11 @@ def _build_linux_shell_cmd(window_id: str) -> str:

The trailing ``exec bash -i`` keeps the window open after the user
detaches from tmux (otherwise the window would snap shut and the
user would lose terminal scrollback).
user would lose terminal scrollback). It is *conditional* on the
group session still existing: a manual detach leaves it alive (keep
the shell), but a session kill tears the group down (see
``tmux_manager._kill_grouped_session_for_window``), so the wrapper
exits and the emulator closes the orphaned tab.
"""
session = shlex.quote(config.tmux_session_name)
group = group_session_name(window_id)
Expand All @@ -214,7 +227,7 @@ def _build_linux_shell_cmd(window_id: str) -> str:
f"tmux new-session -d -t {session} -s {group_q} 2>/dev/null; "
f"tmux select-window -t {target_q} 2>/dev/null; "
f"tmux attach-session -t {group_q}; "
"exec bash -i"
f"tmux has-session -t {group_q} 2>/dev/null && exec bash -i"
)


Expand Down
22 changes: 22 additions & 0 deletions tests/ccbot/test_local_terminal.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,19 @@ def test_keeps_window_open_after_detach(self) -> None:
cmd = _build_tmux_command("@1")
assert "exec ${SHELL:-bash} -l" in cmd

def test_exec_guarded_by_has_session(self) -> None:
"""The tail shell is conditional: it only runs while the group
session is still alive (manual detach). On a session kill the
group is gone, ``has-session`` fails, and the wrapper exits so
the host terminal closes the orphaned tab instead of leaving a
bare login shell."""
cmd = _build_tmux_command("@5")
assert "has-session -t" in cmd
# The guard must short-circuit the exec — ``&&`` immediately
# before it.
idx = cmd.index("has-session")
assert "&& exec ${SHELL:-bash} -l" in cmd[idx:]

def test_wrapped_in_bash_c_for_shell_semantics(self) -> None:
"""Without `bash -c`, iTerm/Terminal.app exec the command without
a shell — ``;`` loses meaning, the chain falls apart."""
Expand Down Expand Up @@ -124,6 +137,15 @@ def test_build_linux_shell_cmd_uses_grouped_session(self) -> None:
assert "attach-session -t" in cmd
assert "exec bash -i" in cmd

def test_linux_exec_guarded_by_has_session(self) -> None:
"""Linux mirrors macOS: the tail shell only survives a manual
detach (group still alive). On kill the group is torn down so
``has-session`` fails and the emulator closes the tab."""
cmd = _build_linux_shell_cmd("@7")
assert "has-session -t" in cmd
idx = cmd.index("has-session")
assert "&& exec bash -i" in cmd[idx:]

def test_expand_linux_template_returns_argv(self) -> None:
argv = _expand_linux_template("gnome-terminal -- bash -c {shell}", "@5")
assert argv[0] == "gnome-terminal"
Expand Down
Loading