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
38 changes: 33 additions & 5 deletions src/utils/__tests__/terminal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ describe('terminal utils', () => {
return 'ttys001\n';
}

if (command === `stty size < /dev/ttys001 | awk '{print $2}'`) {
if (command === `stty -F /dev/ttys001 size 2>/dev/null | awk '{print $2}'`) {
return '120\n';
}

Expand All @@ -57,7 +57,7 @@ describe('terminal utils', () => {
expect(mockExecSync.mock.calls.map(([command]) => command)).toEqual([
`ps -o ppid= -p ${process.pid}`,
'ps -o tty= -p 1234',
`stty size < /dev/ttys001 | awk '{print $2}'`
`stty -F /dev/ttys001 size 2>/dev/null | awk '{print $2}'`
]);
});

Expand All @@ -79,7 +79,7 @@ describe('terminal utils', () => {
return ' ttys009 \n';
}

if (command === `stty size < /dev/ttys009 | awk '{print $2}'`) {
if (command === `stty -F /dev/ttys009 size 2>/dev/null | awk '{print $2}'`) {
return '104\n';
}

Expand All @@ -89,6 +89,32 @@ describe('terminal utils', () => {
expect(getTerminalWidth()).toBe(104);
});

it('falls back through stty variants when the first form returns no value', () => {
// Simulates BSD/macOS, where `stty -F` exits with an error and yields
// empty output via the `2>/dev/null | awk` pipeline; `stty -f` succeeds.
mockExecSync.mockImplementation((command: string) => {
if (command === `ps -o ppid= -p ${process.pid}`) {
return '1234\n';
}

if (command === 'ps -o tty= -p 1234') {
return 'ttys003\n';
}

if (command === `stty -F /dev/ttys003 size 2>/dev/null | awk '{print $2}'`) {
return '\n';
}

if (command === `stty -f /dev/ttys003 size 2>/dev/null | awk '{print $2}'`) {
return '142\n';
}

throw new Error(`Unexpected command: ${command}`);
});

expect(getTerminalWidth()).toBe(142);
});

it('falls back to tput cols when ancestor probing fails', () => {
mockExecSync.mockImplementationOnce(() => { throw new Error('ps unavailable'); });
mockExecSync.mockReturnValueOnce('90\n');
Expand All @@ -107,7 +133,9 @@ describe('terminal utils', () => {
return 'ttys001\n';
}

if (command === `stty size < /dev/ttys001 | awk '{print $2}'`) {
if (command === `stty -F /dev/ttys001 size 2>/dev/null | awk '{print $2}'`
|| command === `stty -f /dev/ttys001 size 2>/dev/null | awk '{print $2}'`
|| command === `stty size < /dev/ttys001 2>/dev/null | awk '{print $2}'`) {
return 'not-a-number\n';
}

Expand Down Expand Up @@ -143,7 +171,7 @@ describe('terminal utils', () => {
return 'ttys010\n';
}

if (command === `stty size < /dev/ttys010 | awk '{print $2}'`) {
if (command === `stty -F /dev/ttys010 size 2>/dev/null | awk '{print $2}'`) {
return '80\n';
}

Expand Down
33 changes: 24 additions & 9 deletions src/utils/terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,20 +119,35 @@ function getTTYForProcess(pid: number): string | null {
}

function getWidthForTTY(tty: string): number | null {
try {
const width = execSync(
`stty size < /dev/${tty} | awk '{print $2}'`,
{
// The shell-redirect form (`stty size < /dev/${tty}`) fails with ENOTTY
// when the calling process has no controlling terminal — the case under
// Claude Code >= 2.1.139, which spawns statusline/hooks without terminal
// access. `stty -F` / `-f` ask stty to open the device itself (with
// O_NOCTTY semantics) and succeed regardless of controlling-tty status.
const devicePath = `/dev/${tty}`;
const attempts = [
`stty -F ${devicePath} size`, // GNU coreutils (Linux)
`stty -f ${devicePath} size`, // BSD stty (macOS, *BSD)
`stty size < ${devicePath}` // legacy fallback
];

for (const cmd of attempts) {
try {
const width = execSync(`${cmd} 2>/dev/null | awk '{print $2}'`, {
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'ignore'],
shell: '/bin/sh'
}).trim();
const parsed = parsePositiveInteger(width);
if (parsed !== null) {
return parsed;
}
).trim();

return parsePositiveInteger(width);
} catch {
return null;
} catch {
// try next strategy
}
}

return null;
}

// Get terminal width
Expand Down