fix(tmux): gate isolated pane activation on visible client focus

This commit is contained in:
Disaster-Terminator
2026-04-18 20:01:18 +08:00
committed by YeonGyu-Kim
parent c63108d55b
commit 4e3684eb2a
8 changed files with 74 additions and 16 deletions
+6
View File
@@ -137,6 +137,7 @@ export class TmuxSessionManager {
this.retryPendingCloses.bind(this), this.retryPendingCloses.bind(this),
this.queryWindowStateSafely.bind(this), this.queryWindowStateSafely.bind(this),
this.activateTrackedSessionPane.bind(this), this.activateTrackedSessionPane.bind(this),
this.canAutoActivatePane.bind(this),
) )
this.deps.log("[tmux-session-manager] initialized", { this.deps.log("[tmux-session-manager] initialized", {
configEnabled: this.tmuxConfig.enabled, configEnabled: this.tmuxConfig.enabled,
@@ -385,6 +386,11 @@ export class TmuxSessionManager {
return true return true
} }
private canAutoActivatePane(state: WindowState): boolean {
if (!this.isIsolated()) return true
return state.windowActive && state.sessionAttached
}
private async closeTrackedSessionPane(args: { private async closeTrackedSessionPane(args: {
tracked: TrackedSession tracked: TrackedSession
state: WindowState state: WindowState
@@ -6,7 +6,7 @@ import { parsePaneStateOutput } from "./pane-state-parser"
describe("parsePaneStateOutput", () => { describe("parsePaneStateOutput", () => {
it("rejects malformed integer fields", () => { it("rejects malformed integer fields", () => {
// given // given
const stdout = "%0\t120oops\t40\t0\t0\t1\t120\t40\n" const stdout = "%0\t120oops\t40\t0\t0\t1\t120\t40\t1\t1\n"
// when // when
const result = parsePaneStateOutput(stdout) const result = parsePaneStateOutput(stdout)
@@ -17,7 +17,7 @@ describe("parsePaneStateOutput", () => {
it("rejects negative integer fields", () => { it("rejects negative integer fields", () => {
// given // given
const stdout = "%0\t-1\t40\t0\t0\t1\t120\t40\n" const stdout = "%0\t-1\t40\t0\t0\t1\t120\t40\t1\t1\n"
// when // when
const result = parsePaneStateOutput(stdout) const result = parsePaneStateOutput(stdout)
@@ -28,7 +28,7 @@ describe("parsePaneStateOutput", () => {
it("rejects empty integer fields", () => { it("rejects empty integer fields", () => {
// given // given
const stdout = "%0\t\t40\t0\t0\t1\t120\t40\n" const stdout = "%0\t\t40\t0\t0\t1\t120\t40\t1\t1\n"
// when // when
const result = parsePaneStateOutput(stdout) const result = parsePaneStateOutput(stdout)
@@ -39,7 +39,7 @@ describe("parsePaneStateOutput", () => {
it("rejects non-binary active flags", () => { it("rejects non-binary active flags", () => {
// given // given
const stdout = "%0\t120\t40\t0\t0\tx\t120\t40\n" const stdout = "%0\t120\t40\t0\t0\tx\t120\t40\t1\t1\n"
// when // when
const result = parsePaneStateOutput(stdout) const result = parsePaneStateOutput(stdout)
@@ -50,7 +50,7 @@ describe("parsePaneStateOutput", () => {
it("rejects numeric active flags other than zero or one", () => { it("rejects numeric active flags other than zero or one", () => {
// given // given
const stdout = "%0\t120\t40\t0\t0\t2\t120\t40\n" const stdout = "%0\t120\t40\t0\t0\t2\t120\t40\t1\t1\n"
// when // when
const result = parsePaneStateOutput(stdout) const result = parsePaneStateOutput(stdout)
@@ -61,7 +61,18 @@ describe("parsePaneStateOutput", () => {
it("rejects empty active flags", () => { it("rejects empty active flags", () => {
// given // given
const stdout = "%0\t120\t40\t0\t0\t\t120\t40\n" const stdout = "%0\t120\t40\t0\t0\t\t120\t40\t1\t1\n"
// when
const result = parsePaneStateOutput(stdout)
// then
expect(result).toBe(null)
})
it("rejects malformed session attached field", () => {
// given
const stdout = "%0\t120\t40\t0\t0\t1\t120\t40\t1\tnope\n"
// when // when
const result = parsePaneStateOutput(stdout) const result = parsePaneStateOutput(stdout)
@@ -1,10 +1,12 @@
import type { TmuxPaneInfo } from "./types" import type { TmuxPaneInfo } from "./types"
const MANDATORY_PANE_FIELD_COUNT = 8 const MANDATORY_PANE_FIELD_COUNT = 10
type ParsedPaneState = { type ParsedPaneState = {
windowWidth: number windowWidth: number
windowHeight: number windowHeight: number
windowActive: boolean
sessionAttached: boolean
panes: TmuxPaneInfo[] panes: TmuxPaneInfo[]
} }
@@ -12,6 +14,8 @@ type ParsedPaneLine = {
pane: TmuxPaneInfo pane: TmuxPaneInfo
windowWidth: number windowWidth: number
windowHeight: number windowHeight: number
windowActive: boolean
sessionAttached: boolean
} }
type MandatoryPaneFields = [ type MandatoryPaneFields = [
@@ -23,6 +27,8 @@ type MandatoryPaneFields = [
activeString: string, activeString: string,
windowWidthString: string, windowWidthString: string,
windowHeightString: string, windowHeightString: string,
windowActiveString: string,
sessionAttachedString: string,
] ]
export function parsePaneStateOutput(stdout: string): ParsedPaneState | null { export function parsePaneStateOutput(stdout: string): ParsedPaneState | null {
@@ -45,6 +51,8 @@ export function parsePaneStateOutput(stdout: string): ParsedPaneState | null {
return { return {
windowWidth: latestPaneLine.windowWidth, windowWidth: latestPaneLine.windowWidth,
windowHeight: latestPaneLine.windowHeight, windowHeight: latestPaneLine.windowHeight,
windowActive: latestPaneLine.windowActive,
sessionAttached: latestPaneLine.sessionAttached,
panes: parsedPaneLines.map(({ pane }) => pane), panes: parsedPaneLines.map(({ pane }) => pane),
} }
} }
@@ -54,7 +62,7 @@ function parsePaneLine(line: string): ParsedPaneLine | null {
const mandatoryFields = getMandatoryPaneFields(fields) const mandatoryFields = getMandatoryPaneFields(fields)
if (!mandatoryFields) return null if (!mandatoryFields) return null
const [paneId, widthString, heightString, leftString, topString, activeString, windowWidthString, windowHeightString] = mandatoryFields const [paneId, widthString, heightString, leftString, topString, activeString, windowWidthString, windowHeightString, windowActiveString, sessionAttachedString] = mandatoryFields
const width = parseInteger(widthString) const width = parseInteger(widthString)
const height = parseInteger(heightString) const height = parseInteger(heightString)
@@ -63,6 +71,8 @@ function parsePaneLine(line: string): ParsedPaneLine | null {
const isActive = parseActiveValue(activeString) const isActive = parseActiveValue(activeString)
const windowWidth = parseInteger(windowWidthString) const windowWidth = parseInteger(windowWidthString)
const windowHeight = parseInteger(windowHeightString) const windowHeight = parseInteger(windowHeightString)
const windowActive = parseActiveValue(windowActiveString)
const sessionAttached = parseAttachedValue(sessionAttachedString)
if ( if (
width === null || width === null ||
@@ -71,7 +81,9 @@ function parsePaneLine(line: string): ParsedPaneLine | null {
top === null || top === null ||
isActive === null || isActive === null ||
windowWidth === null || windowWidth === null ||
windowHeight === null windowHeight === null ||
windowActive === null ||
sessionAttached === null
) { ) {
return null return null
} }
@@ -88,13 +100,15 @@ function parsePaneLine(line: string): ParsedPaneLine | null {
}, },
windowWidth, windowWidth,
windowHeight, windowHeight,
windowActive,
sessionAttached,
} }
} }
function getMandatoryPaneFields(fields: string[]): MandatoryPaneFields | null { function getMandatoryPaneFields(fields: string[]): MandatoryPaneFields | null {
if (fields.length < MANDATORY_PANE_FIELD_COUNT) return null if (fields.length < MANDATORY_PANE_FIELD_COUNT) return null
const [paneId, widthString, heightString, leftString, topString, activeString, windowWidthString, windowHeightString] = fields const [paneId, widthString, heightString, leftString, topString, activeString, windowWidthString, windowHeightString, windowActiveString, sessionAttachedString] = fields
if ( if (
paneId === undefined || paneId === undefined ||
@@ -104,7 +118,9 @@ function getMandatoryPaneFields(fields: string[]): MandatoryPaneFields | null {
topString === undefined || topString === undefined ||
activeString === undefined || activeString === undefined ||
windowWidthString === undefined || windowWidthString === undefined ||
windowHeightString === undefined windowHeightString === undefined ||
windowActiveString === undefined ||
sessionAttachedString === undefined
) { ) {
return null return null
} }
@@ -118,6 +134,8 @@ function getMandatoryPaneFields(fields: string[]): MandatoryPaneFields | null {
activeString, activeString,
windowWidthString, windowWidthString,
windowHeightString, windowHeightString,
windowActiveString,
sessionAttachedString,
] ]
} }
@@ -133,3 +151,8 @@ function parseActiveValue(value: string): boolean | null {
if (value === "0") return false if (value === "0") return false
return null return null
} }
function parseAttachedValue(value: string): boolean | null {
if (!/^\d+$/.test(value)) return null
return Number.parseInt(value, 10) > 0
}
@@ -6,7 +6,7 @@ import { parsePaneStateOutput } from "./pane-state-parser"
describe("parsePaneStateOutput", () => { describe("parsePaneStateOutput", () => {
it("accepts a single pane when tmux omits the empty trailing title field", () => { it("accepts a single pane when tmux omits the empty trailing title field", () => {
// given // given
const stdout = "%0\t120\t40\t0\t0\t1\t120\t40\n" const stdout = "%0\t120\t40\t0\t0\t1\t120\t40\t1\t1\n"
// when // when
const result = parsePaneStateOutput(stdout) const result = parsePaneStateOutput(stdout)
@@ -16,6 +16,8 @@ describe("parsePaneStateOutput", () => {
expect(result).toEqual({ expect(result).toEqual({
windowWidth: 120, windowWidth: 120,
windowHeight: 40, windowHeight: 40,
windowActive: true,
sessionAttached: true,
panes: [ panes: [
{ {
paneId: "%0", paneId: "%0",
@@ -32,7 +34,7 @@ describe("parsePaneStateOutput", () => {
it("handles CRLF line endings without dropping panes", () => { it("handles CRLF line endings without dropping panes", () => {
// given // given
const stdout = "%0\t120\t40\t0\t0\t1\t120\t40\r\n%1\t60\t40\t60\t0\t0\t120\t40\tagent\r\n" const stdout = "%0\t120\t40\t0\t0\t1\t120\t40\t1\t1\r\n%1\t60\t40\t60\t0\t0\t120\t40\t1\t1\tagent\r\n"
// when // when
const result = parsePaneStateOutput(stdout) const result = parsePaneStateOutput(stdout)
@@ -63,13 +65,15 @@ describe("parsePaneStateOutput", () => {
it("preserves tabs inside pane titles", () => { it("preserves tabs inside pane titles", () => {
// given // given
const stdout = "%0\t120\t40\t0\t0\t1\t120\t40\ttitle\twith\ttabs\n" const stdout = "%0\t120\t40\t0\t0\t1\t120\t40\t0\t0\ttitle\twith\ttabs\n"
// when // when
const result = parsePaneStateOutput(stdout) const result = parsePaneStateOutput(stdout)
// then // then
expect(result).not.toBe(null) expect(result).not.toBe(null)
expect(result?.windowActive).toBe(false)
expect(result?.sessionAttached).toBe(false)
expect(result?.panes[0]?.title).toBe("title\twith\ttabs") expect(result?.panes[0]?.title).toBe("title\twith\ttabs")
}) })
}) })
@@ -19,7 +19,7 @@ export async function queryWindowStateWithDeps(sourcePaneId: string, deps: Query
"-t", "-t",
sourcePaneId, sourcePaneId,
"-F", "-F",
"#{pane_id}\t#{pane_width}\t#{pane_height}\t#{pane_left}\t#{pane_top}\t#{pane_active}\t#{window_width}\t#{window_height}\t#{pane_title}", "#{pane_id}\t#{pane_width}\t#{pane_height}\t#{pane_left}\t#{pane_top}\t#{pane_active}\t#{window_width}\t#{window_height}\t#{window_active}\t#{session_attached}\t#{pane_title}",
]) ])
if (result.exitCode !== 0) { if (result.exitCode !== 0) {
@@ -38,6 +38,8 @@ export async function queryWindowStateWithDeps(sourcePaneId: string, deps: Query
const { panes } = parsedPaneState const { panes } = parsedPaneState
const windowWidth = parsedPaneState.windowWidth const windowWidth = parsedPaneState.windowWidth
const windowHeight = parsedPaneState.windowHeight const windowHeight = parsedPaneState.windowHeight
const windowActive = parsedPaneState.windowActive
const sessionAttached = parsedPaneState.sessionAttached
panes.sort((a, b) => a.left - b.left || a.top - b.top) panes.sort((a, b) => a.left - b.left || a.top - b.top)
@@ -71,7 +73,7 @@ export async function queryWindowStateWithDeps(sourcePaneId: string, deps: Query
agentPaneCount: agentPanes.length, agentPaneCount: agentPanes.length,
}) })
return { windowWidth, windowHeight, mainPane, agentPanes } return { windowWidth, windowHeight, windowActive, sessionAttached, mainPane, agentPanes }
} }
export async function queryWindowState(sourcePaneId: string): Promise<WindowState | null> { export async function queryWindowState(sourcePaneId: string): Promise<WindowState | null> {
@@ -269,6 +269,8 @@ describe("TmuxPollingManager overlap", () => {
const windowState: WindowState = { const windowState: WindowState = {
windowWidth: 160, windowWidth: 160,
windowHeight: 48, windowHeight: 48,
windowActive: true,
sessionAttached: true,
mainPane: null, mainPane: null,
agentPanes: [ agentPanes: [
{ paneId: "%1", width: 80, height: 24, left: 0, top: 0, title: "agent", isActive: true }, { paneId: "%1", width: 80, height: 24, left: 0, top: 0, title: "agent", isActive: true },
@@ -24,6 +24,7 @@ export class TmuxPollingManager {
private retryPendingCloses?: () => Promise<void>, private retryPendingCloses?: () => Promise<void>,
private getWindowState?: () => Promise<WindowState | null>, private getWindowState?: () => Promise<WindowState | null>,
private activateSessionPane?: (tracked: TrackedSession) => Promise<boolean>, private activateSessionPane?: (tracked: TrackedSession) => Promise<boolean>,
private canActivatePane: (state: WindowState) => boolean = (state) => state.windowActive !== false && state.sessionAttached !== false,
) {} ) {}
handleEvent(event: { type: string; properties?: Record<string, unknown> }): void { handleEvent(event: { type: string; properties?: Record<string, unknown> }): void {
@@ -220,6 +221,13 @@ export class TmuxPollingManager {
const state = await this.getWindowState().catch(() => null) const state = await this.getWindowState().catch(() => null)
if (!state) return if (!state) return
if (this.canActivatePane && !this.canActivatePane(state)) {
log("[tmux-session-manager] activation gate blocked auto-attach", {
windowActive: state.windowActive,
sessionAttached: state.sessionAttached,
})
return
}
const panes = [state.mainPane, ...state.agentPanes].filter((pane): pane is NonNullable<typeof pane> => Boolean(pane)) const panes = [state.mainPane, ...state.agentPanes].filter((pane): pane is NonNullable<typeof pane> => Boolean(pane))
const activePaneIds = new Set(panes.filter((pane) => pane.isActive).map((pane) => pane.paneId)) const activePaneIds = new Set(panes.filter((pane) => pane.isActive).map((pane) => pane.paneId))
+2
View File
@@ -31,6 +31,8 @@ export interface TmuxPaneInfo {
export interface WindowState { export interface WindowState {
windowWidth: number windowWidth: number
windowHeight: number windowHeight: number
windowActive?: boolean
sessionAttached?: boolean
mainPane: TmuxPaneInfo | null mainPane: TmuxPaneInfo | null
agentPanes: TmuxPaneInfo[] agentPanes: TmuxPaneInfo[]
} }