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
10 changes: 7 additions & 3 deletions scripts/tmux/genie.tmux.conf
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ set -g renumber-windows on
set -g history-limit 50000
set -g escape-time 0
set -g focus-events on
set -g set-clipboard on
set -g set-clipboard external
set -g allow-passthrough on
set -ga terminal-overrides ",*:Ms=\\E]52;c;%p2%s\\7"

# --- Mouse & vi mode ---
set -g mouse on
Expand Down Expand Up @@ -97,9 +99,11 @@ bind r source-file ~/.tmux.conf \; display-message "Config reloaded"
# Ctrl+T — new tab in current session
bind -n C-t new-window -c "#{?session_path,#{session_path},#{pane_current_path}}"

# Vi copy mode bindings
# Vi copy mode bindings — pipe to osc52-copy.sh for clipboard over SSH
bind -T copy-mode-vi v send-keys -X begin-selection
bind -T copy-mode-vi y send-keys -X copy-selection-and-cancel
bind -T copy-mode-vi y send-keys -X copy-pipe-and-cancel "~/.genie/osc52-copy.sh"
bind -T copy-mode-vi Enter send-keys -X copy-pipe-and-cancel "~/.genie/osc52-copy.sh"
bind -T copy-mode-vi MouseDragEnd1Pane send-keys -X copy-pipe-and-cancel "~/.genie/osc52-copy.sh"

# Session switching — Alt+[ / Alt+] cycle sessions (works in all terminals)
bind -n M-] switch-client -n
Expand Down
16 changes: 16 additions & 0 deletions scripts/tmux/osc52-copy.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
#!/usr/bin/env bash
# osc52-copy.sh — Write OSC 52 clipboard sequence directly to the SSH PTY,
# bypassing nested tmux layers that may absorb the sequence.
set -euo pipefail

buf=$(cat "$@")
encoded=$(printf '%s' "$buf" | base64 | tr -d '\n')
Comment on lines +6 to +7

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

Capturing the input into a variable using buf=$(cat "$@") strips trailing newlines from the copied content, which is likely unintended for a clipboard helper. Additionally, for large clipboard buffers, it is more efficient to pipe the data directly to base64 to avoid unnecessary memory allocation in the shell.

Suggested change
buf=$(cat "$@")
encoded=$(printf '%s' "$buf" | base64 | tr -d '\n')
encoded=$(cat "$@" | base64 | tr -d '\n')

seq=$(printf '\033]52;c;%s\a' "$encoded")

# Try writing directly to the SSH connection's PTY
for tty in $(who -m 2>/dev/null | awk '{print $2}'); do
printf '%s' "$seq" > "/dev/$tty" 2>/dev/null || true
done

# Fallback: emit to stdout (works if tmux allow-passthrough is on)
printf '%s' "$seq"
8 changes: 5 additions & 3 deletions scripts/tmux/tui-tmux.conf
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,19 @@ set -g destroy-unattached off
set -g exit-empty off

# Clipboard
set -g set-clipboard on
set -g set-clipboard external
set -g allow-passthrough on
set -ga terminal-overrides ",*:Ms=\\E]52;c;%p2%s\\7"

# Mouse on — needed for clicking between left nav and right terminal panes
set -g mouse on
setw -g mode-keys vi

# Vi copy mode
# Vi copy mode — pipe to osc52-copy.sh for clipboard over SSH
bind -T copy-mode-vi v send-keys -X begin-selection
bind -T copy-mode-vi y send-keys -X copy-selection-and-cancel
bind -T copy-mode-vi y send-keys -X copy-pipe-and-cancel "~/.genie/osc52-copy.sh"
bind -T copy-mode-vi Enter send-keys -X copy-pipe-and-cancel "~/.genie/osc52-copy.sh"
bind -T copy-mode-vi MouseDragEnd1Pane send-keys -X copy-pipe-and-cancel "~/.genie/osc52-copy.sh"

# NO status bars, NO pane-border shell probes — TUI renders its own UI
set -g status off
Expand Down
60 changes: 58 additions & 2 deletions src/__tests__/tmux-config.test.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,24 @@
/**
* Regression tests for genie.tmux.conf
* Regression tests for genie.tmux.conf and tui-tmux.conf
*
* Ensures status-bar click handling works for both windows (single-click)
* and sessions (double-click on any status area).
* See: https://github.com/automagik-dev/genie/issues/784
*
* Also ensures OSC 52 clipboard passthrough works in nested tmux layers.
* See: https://github.com/automagik-dev/genie/issues/967
*/

import { describe, expect, test } from 'bun:test';
import { readFileSync } from 'node:fs';
import { existsSync, readFileSync } from 'node:fs';
import { resolve } from 'node:path';

const CONF_PATH = resolve(import.meta.dirname, '../../scripts/tmux/genie.tmux.conf');
const conf = readFileSync(CONF_PATH, 'utf-8');

const TUI_CONF_PATH = resolve(import.meta.dirname, '../../scripts/tmux/tui-tmux.conf');
const tuiConf = readFileSync(TUI_CONF_PATH, 'utf-8');

/** Return non-comment, non-empty lines that match a pattern. */
function activeLines(pattern: RegExp): string[] {
return conf
Expand Down Expand Up @@ -64,3 +70,53 @@ describe('tmux config — status bar click handling', () => {
expect(activeLines(/DoubleClick1StatusRight/).length).toBe(1);
});
});

/** Return non-comment, non-empty lines from a config that match a pattern. */
function activeLinesIn(config: string, pattern: RegExp): string[] {
return config
.split('\n')
.filter((l) => !l.trimStart().startsWith('#') && l.trim() !== '')
.filter((l) => pattern.test(l));
}

describe('tmux config — OSC 52 clipboard passthrough (#967)', () => {
test('genie.tmux.conf uses set-clipboard external (not on)', () => {
const hits = activeLinesIn(conf, /set\s+-g\s+set-clipboard\s+external/);
expect(hits.length).toBe(1);
// Must NOT have "set-clipboard on"
const bad = activeLinesIn(conf, /set\s+-g\s+set-clipboard\s+on/);
expect(bad.length).toBe(0);
});

test('genie.tmux.conf has allow-passthrough on', () => {
const hits = activeLinesIn(conf, /set\s+-g\s+allow-passthrough\s+on/);
expect(hits.length).toBe(1);
});

test('genie.tmux.conf has terminal-overrides for Ms (OSC 52)', () => {
const hits = activeLinesIn(conf, /terminal-overrides.*Ms=/);
expect(hits.length).toBeGreaterThanOrEqual(1);
});

test('genie.tmux.conf uses copy-pipe-and-cancel with osc52-copy.sh', () => {
const hits = activeLinesIn(conf, /copy-pipe-and-cancel.*osc52-copy\.sh/);
expect(hits.length).toBeGreaterThanOrEqual(1);
});

test('tui-tmux.conf uses set-clipboard external (not on)', () => {
const hits = activeLinesIn(tuiConf, /set\s+-g\s+set-clipboard\s+external/);
expect(hits.length).toBe(1);
const bad = activeLinesIn(tuiConf, /set\s+-g\s+set-clipboard\s+on/);
expect(bad.length).toBe(0);
});

test('tui-tmux.conf uses copy-pipe-and-cancel with osc52-copy.sh', () => {
const hits = activeLinesIn(tuiConf, /copy-pipe-and-cancel.*osc52-copy\.sh/);
expect(hits.length).toBeGreaterThanOrEqual(1);
});

test('osc52-copy.sh helper script exists and is executable', () => {
const scriptPath = resolve(import.meta.dirname, '../../scripts/tmux/osc52-copy.sh');
expect(existsSync(scriptPath)).toBe(true);
});
});
16 changes: 14 additions & 2 deletions src/genie-commands/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -431,8 +431,8 @@ export async function setupCommand(options: SetupOptions = {}): Promise<void> {

/** Copy shipped genie.tmux.conf to ~/.genie/tmux.conf if it doesn't exist yet. */
function installGenieTmuxConf(): void {
const { existsSync, copyFileSync, mkdirSync } = require('node:fs') as typeof import('node:fs');
const { resolve } = require('node:path') as typeof import('node:path');
const { existsSync, copyFileSync, mkdirSync, chmodSync } = require('node:fs') as typeof import('node:fs');
const { resolve, dirname } = require('node:path') as typeof import('node:path');
const genieHome = process.env.GENIE_HOME ?? join(homedir(), '.genie');
const dest = join(genieHome, 'tmux.conf');
if (existsSync(dest)) return; // already installed

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Install OSC52 helper even when tmux.conf already exists

The early return on existing ~/.genie/tmux.conf prevents setup from installing ~/.genie/osc52-copy.sh, because the new helper-copy logic is placed after that return. This makes setup non-idempotent for the new clipboard path: if a user has a preexisting tmux config but is missing the helper script (for example after partial cleanup or a prior interrupted install), rerunning setup cannot repair clipboard copy bindings that now reference ~/.genie/osc52-copy.sh.

Useful? React with 👍 / 👎.

Expand All @@ -452,4 +452,16 @@ function installGenieTmuxConf(): void {
} catch {
// non-fatal
}

// Install osc52-copy.sh clipboard helper alongside the tmux config
const osc52Src = join(dirname(src), 'osc52-copy.sh');
const osc52Dest = join(genieHome, 'osc52-copy.sh');
if (existsSync(osc52Src)) {
try {
copyFileSync(osc52Src, osc52Dest);
chmodSync(osc52Dest, 0o755);
} catch {
// non-fatal
}
}
}
13 changes: 13 additions & 0 deletions src/genie-commands/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -439,6 +439,19 @@ function syncTmuxConf(tmuxScriptsSrc: string): void {
// Read/write failed — non-fatal
}
}

// Install osc52-copy.sh → ~/.genie/osc52-copy.sh (clipboard helper for nested tmux)
const osc52Src = join(tmuxScriptsSrc, 'osc52-copy.sh');
const osc52Dest = join(GENIE_HOME, 'osc52-copy.sh');
if (existsSync(osc52Src)) {
try {
copyFileSync(osc52Src, osc52Dest);
chmodSync(osc52Dest, 0o755);
success(`Installed OSC 52 clipboard helper to ${osc52Dest}`);
} catch {
// Read/write failed — non-fatal
}
}
}

/** Copy tmux scripts from the global package to ~/.genie/scripts/ */
Expand Down
Loading