fix(tmux): gate isolated pane activation on visible client focus
This commit is contained in:
committed by
YeonGyu-Kim
parent
c63108d55b
commit
4e3684eb2a
@@ -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))
|
||||||
|
|||||||
@@ -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[]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user