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
30 changes: 30 additions & 0 deletions src/backend/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,36 @@ export const createTmuxMobileServer = (
case "send_compose":
runtime.write(`${message.text}\r`);
return;
case "rename_session":
await deps.tmux.renameSession(message.session, message.newName);
return;
case "rename_window":
await deps.tmux.renameWindow(message.session, message.windowIndex, message.newName);
return;
case "kill_session": {
await deps.tmux.killSession(message.session);
await ensureAttachedSession(socket);
return;
}
case "respawn_pane":
await deps.tmux.respawnPane(message.paneId);
return;
case "break_pane":
await deps.tmux.breakPane(message.paneId);
return;
case "swap_window":
await deps.tmux.swapWindow(message.session, message.srcIndex, message.dstIndex);
return;
case "set_session_default_directory":
await deps.tmux.setSessionDefaultDirectory(message.session, message.directory ?? null);
return;
case "set_window_default_directory":
await deps.tmux.setWindowDefaultDirectory(
message.session,
message.windowIndex,
message.directory ?? null
);
return;
case "auth":
return;
default: {
Expand Down
113 changes: 111 additions & 2 deletions src/backend/tmux/cli-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ const execFileAsync = promisify(execFile);
const SESSION_FMT = "#{session_name}\t#{session_attached}\t#{session_windows}";
const WINDOW_FMT = "#{window_index}\t#{window_name}\t#{window_active}\t#{window_panes}";
const PANE_FMT = "#{pane_index}\t#{pane_id}\t#{pane_current_command}\t#{pane_active}\t#{pane_width}x#{pane_height}";
const SESSION_DEFAULT_DIRECTORY_OPTION = "@tmux_mobile_session_default_directory";
const WINDOW_DEFAULT_DIRECTORY_OPTION = "@tmux_mobile_window_default_directory";

interface TmuxCliExecutorOptions {
socketName?: string;
Expand Down Expand Up @@ -77,6 +79,23 @@ export class TmuxCliExecutor implements TmuxGateway {
}
}

private async showOption(args: string[]): Promise<string | null> {
try {
const value = await this.runTmux(["show-options", "-qv", ...args]);
return value === "" ? null : value;
} catch {
return null;
}
}

private async getSessionDefaultDirectory(session: string): Promise<string | null> {
return this.showOption(["-t", session, SESSION_DEFAULT_DIRECTORY_OPTION]);
}

private async getWindowDefaultDirectory(windowId: string): Promise<string | null> {
return this.showOption(["-w", "-t", windowId, WINDOW_DEFAULT_DIRECTORY_OPTION]);
}

public async listSessions() {
const output = await this.runTmuxMaybeNoServer(["list-sessions", "-F", SESSION_FMT]);
if (!output) {
Expand Down Expand Up @@ -120,7 +139,12 @@ export class TmuxCliExecutor implements TmuxGateway {
}

public async newWindow(session: string): Promise<void> {
await this.runTmux(["new-window", "-t", session]);
const defaultDirectory = await this.getSessionDefaultDirectory(session);
const args = ["new-window", "-t", session];
if (defaultDirectory) {
args.push("-c", defaultDirectory);
}
await this.runTmux(args);
}

public async killWindow(session: string, windowIndex: number): Promise<void> {
Expand All @@ -132,7 +156,20 @@ export class TmuxCliExecutor implements TmuxGateway {
}

public async splitWindow(paneId: string, orientation: "h" | "v"): Promise<void> {
await this.runTmux(["split-window", `-${orientation}`, "-t", paneId]);
const [windowId, sessionName] = await Promise.all([
this.runTmux(["display-message", "-p", "-t", paneId, "#{window_id}"]),
this.runTmux(["display-message", "-p", "-t", paneId, "#{session_name}"])
]);
const windowDirectory = await this.getWindowDefaultDirectory(windowId);
const sessionDirectory = await this.getSessionDefaultDirectory(sessionName);
const defaultDirectory = windowDirectory ?? sessionDirectory;

const args = ["split-window", `-${orientation}`];
if (defaultDirectory) {
args.push("-c", defaultDirectory);
}
args.push("-t", paneId);
await this.runTmux(args);
}

public async killPane(paneId: string): Promise<void> {
Expand All @@ -150,4 +187,76 @@ export class TmuxCliExecutor implements TmuxGateway {
public async capturePane(paneId: string, lines: number): Promise<string> {
return this.runTmux(["capture-pane", "-t", paneId, "-p", "-S", `-${lines}`]);
}

public async renameSession(oldName: string, newName: string): Promise<void> {
await this.runTmux(["rename-session", "-t", oldName, newName]);
}

public async renameWindow(session: string, windowIndex: number, newName: string): Promise<void> {
await this.runTmux(["rename-window", "-t", `${session}:${windowIndex}`, newName]);
}

public async respawnPane(paneId: string): Promise<void> {
await this.runTmux(["respawn-pane", "-k", "-t", paneId]);
}

public async breakPane(paneId: string): Promise<void> {
await this.runTmux(["break-pane", "-t", paneId]);
Comment on lines +203 to +204

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

tmux break-pane -s vs -t flag source pane documentation

💡 Result:

In tmux break-pane:

  • -s src-pane specifies the source pane to break out (the pane you are moving into its own window). If omitted, tmux uses the current pane. The src-pane is addressed like session:window.pane (for example: break-pane -s Advanced:1.2). [1][2]
  • -t dst-window specifies the destination window (the window that will contain the broken-out pane as its only pane). If omitted, tmux creates a new window (at the next available index) for the pane. [1]

Version note: older tmux versions used -t target-pane (without -s); newer versions split this into -s src-pane and -t dst-window. [2]

Sources:
[1] tmux man page (break-pane ... [-s src-pane] [-t dst-window]) https://linuxcommand.org/lc3_man_pages/tmux1.html
[2] tmux_interface docs quoting tmux manuals across versions https://docs.rs/tmux_interface/latest/tmux_interface/commands/windows_and_panes/break_pane/struct.BreakPane.html


Use -s flag instead of -t to specify the source pane.

In modern tmux break-pane, -s src-pane specifies the source pane to break out, while -t dst-window specifies the destination window (optional; if omitted, a new window is created). The current code passes paneId with -t, which targets the wrong thing. Switch to -s:

  public async breakPane(paneId: string): Promise<void> {
-   await this.runTmux(["break-pane", "-t", paneId]);
+   await this.runTmux(["break-pane", "-s", paneId]);
  }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public async breakPane(paneId: string): Promise<void> {
await this.runTmux(["break-pane", "-t", paneId]);
public async breakPane(paneId: string): Promise<void> {
await this.runTmux(["break-pane", "-s", paneId]);
}

}

public async swapWindow(session: string, srcIndex: number, dstIndex: number): Promise<void> {
await this.runTmux(["swap-window", "-s", `${session}:${srcIndex}`, "-t", `${session}:${dstIndex}`]);
}

public async setSessionDefaultDirectory(
session: string,
directory: string | null
): Promise<void> {
if (!directory) {
await this.runTmux([
"set-option",
"-u",
"-t",
session,
SESSION_DEFAULT_DIRECTORY_OPTION
]);
return;
}

await this.runTmux([
"set-option",
"-t",
session,
SESSION_DEFAULT_DIRECTORY_OPTION,
directory
]);
}

public async setWindowDefaultDirectory(
session: string,
windowIndex: number,
directory: string | null
): Promise<void> {
const target = `${session}:${windowIndex}`;
if (!directory) {
await this.runTmux([
"set-option",
"-u",
"-w",
"-t",
target,
WINDOW_DEFAULT_DIRECTORY_OPTION
]);
return;
}

await this.runTmux([
"set-option",
"-w",
"-t",
target,
WINDOW_DEFAULT_DIRECTORY_OPTION,
directory
]);
}
}
11 changes: 11 additions & 0 deletions src/backend/tmux/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,17 @@ export interface TmuxGateway {
selectPane(paneId: string): Promise<void>;
zoomPane(paneId: string): Promise<void>;
capturePane(paneId: string, lines: number): Promise<string>;
renameSession(oldName: string, newName: string): Promise<void>;
renameWindow(session: string, windowIndex: number, newName: string): Promise<void>;
respawnPane(paneId: string): Promise<void>;
breakPane(paneId: string): Promise<void>;
swapWindow(session: string, srcIndex: number, dstIndex: number): Promise<void>;
setSessionDefaultDirectory(session: string, directory: string | null): Promise<void>;
setWindowDefaultDirectory(
session: string,
windowIndex: number,
directory: string | null
): Promise<void>;
}

export const buildSnapshot = async (
Expand Down
10 changes: 9 additions & 1 deletion src/backend/types/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,15 @@ export type ControlClientMessage =
| { type: "kill_pane"; paneId: string }
| { type: "zoom_pane"; paneId: string }
| { type: "capture_scrollback"; paneId: string; lines?: number }
| { type: "send_compose"; text: string };
| { type: "send_compose"; text: string }
| { type: "rename_session"; session: string; newName: string }
| { type: "rename_window"; session: string; windowIndex: number; newName: string }
| { type: "kill_session"; session: string }
| { type: "respawn_pane"; paneId: string }
| { type: "break_pane"; paneId: string }
| { type: "swap_window"; session: string; srcIndex: number; dstIndex: number }
| { type: "set_session_default_directory"; session: string; directory?: string }
| { type: "set_window_default_directory"; session: string; windowIndex: number; directory?: string };

export interface TmuxSessionSummary {
name: string;
Expand Down
Loading