From 9d56d93ce1a8879f0cb282fa8171ab0c5f45ebd8 Mon Sep 17 00:00:00 2001 From: axisrow Date: Fri, 25 Sep 2026 11:18:55 +0800 Subject: [PATCH 1/3] perf: cut per-repaint subprocess and module-graph cost (#397) With several concurrent Claude Code sessions every statusline invocation is a fresh process, so per-render fixed costs multiply by session count. - terminal: probe each ancestor with a single `ps -o ppid=,tty=` call instead of two spawns per ancestor; the walk runs per render on macOS where numeric widths are deliberately not persisted - usage-fetch, claude-service-status: load https-proxy-agent (and its transitive graph) on demand, only when a proxy is actually configured Benchmark (13MB transcript, 4 parallel workers x 20 renders via stdin): 27.2 -> 20.6 CPU-seconds total, 0.34 -> 0.26 CPU-s per render (-24%), wall 7.5s -> 5.6s. node --cpu-prof shows the undici/proxy graph gone from startup (compileForInternalLoader 40.7ms -> 7.5ms per render). Co-Authored-By: Claude Code --- src/utils/__tests__/terminal.test.ts | 101 +++++++++------------------ src/utils/claude-service-status.ts | 98 +++++++++++++++----------- src/utils/terminal.ts | 49 ++++++------- src/utils/usage-fetch.ts | 26 ++++--- 4 files changed, 131 insertions(+), 143 deletions(-) diff --git a/src/utils/__tests__/terminal.test.ts b/src/utils/__tests__/terminal.test.ts index 1f40ef987..d5f03697a 100644 --- a/src/utils/__tests__/terminal.test.ts +++ b/src/utils/__tests__/terminal.test.ts @@ -66,12 +66,8 @@ describe('terminal utils', () => { it('returns width from the immediate parent tty when available', () => { pinPosixPlatform(); mockExecFileSync.mockImplementation((file: string, args: string[]) => { - if (file === 'ps' && args.join(' ') === `-o ppid= -p ${process.pid}`) { - return '1234\n'; - } - - if (file === 'ps' && args.join(' ') === '-o tty= -p 1234') { - return 'ttys001\n'; + if (file === 'ps' && args.join(' ') === `-o ppid=,tty= -p ${process.pid}`) { + return '1234 ttys001\n'; } if (file === 'stty' && args.join(' ') === '-F /dev/ttys001 size') { @@ -83,8 +79,7 @@ describe('terminal utils', () => { expect(getTerminalWidth()).toBe(120); expect(mockExecFileSync.mock.calls.map(([file, args]) => `${file as string} ${(args as string[]).join(' ')}`)).toEqual([ - `ps -o ppid= -p ${process.pid}`, - 'ps -o tty= -p 1234', + `ps -o ppid=,tty= -p ${process.pid}`, 'stty -F /dev/ttys001 size' ]); }); @@ -92,20 +87,16 @@ describe('terminal utils', () => { it('walks ancestor processes until it finds a valid tty', () => { pinPosixPlatform(); mockExecFileSync.mockImplementation((file: string, args: string[]) => { - if (file === 'ps' && args.join(' ') === `-o ppid= -p ${process.pid}`) { - return '1234\n'; + if (file === 'ps' && args.join(' ') === `-o ppid=,tty= -p ${process.pid}`) { + return '1234 ?\n'; } - if (file === 'ps' && args.join(' ') === '-o tty= -p 1234') { - return '??\n'; + if (file === 'ps' && args.join(' ') === '-o ppid=,tty= -p 1234') { + return '5678 ??\n'; } - if (file === 'ps' && args.join(' ') === '-o ppid= -p 1234') { - return '5678\n'; - } - - if (file === 'ps' && args.join(' ') === '-o tty= -p 5678') { - return ' ttys009 \n'; + if (file === 'ps' && args.join(' ') === '-o ppid=,tty= -p 5678') { + return '1 ttys009\n'; } if (file === 'stty' && args.join(' ') === '-F /dev/ttys009 size') { @@ -122,12 +113,8 @@ describe('terminal utils', () => { pinPosixPlatform(); // Simulates BSD/macOS, where `stty -F` exits with an error; `stty -f` succeeds. mockExecFileSync.mockImplementation((file: string, args: string[]) => { - if (file === 'ps' && args.join(' ') === `-o ppid= -p ${process.pid}`) { - return '1234\n'; - } - - if (file === 'ps' && args.join(' ') === '-o tty= -p 1234') { - return 'ttys003\n'; + if (file === 'ps' && args.join(' ') === `-o ppid=,tty= -p ${process.pid}`) { + return '1234 ttys003\n'; } if (file === 'stty' && args.join(' ') === '-F /dev/ttys003 size') { @@ -154,25 +141,21 @@ describe('terminal utils', () => { expect(mockExecFileSync.mock.calls[1]?.[1]).toEqual(['cols']); }); - it('returns null when ancestor and fallback probes fail', () => { + it('returns null when ancestor and fallback probes all fail', () => { pinPosixPlatform(); mockExecFileSync.mockImplementation((file: string, args: string[]) => { - if (file === 'ps' && args.join(' ') === `-o ppid= -p ${process.pid}`) { - return '1234\n'; + if (file === 'ps' && args.join(' ') === `-o ppid=,tty= -p ${process.pid}`) { + return '1234 ttys001\n'; } - if (file === 'ps' && args.join(' ') === '-o tty= -p 1234') { - return 'ttys001\n'; + if (file === 'ps' && args.join(' ') === '-o ppid=,tty= -p 1234') { + return '0 ttys001\n'; } if (file === 'stty') { return 'not-a-number not-a-number\n'; } - if (file === 'ps' && args.join(' ') === '-o ppid= -p 1234') { - return '0\n'; - } - if (file === 'tput') { throw new Error('tput unavailable'); } @@ -186,20 +169,16 @@ describe('terminal utils', () => { it('detects availability when an ancestor tty probe succeeds', () => { pinPosixPlatform(); mockExecFileSync.mockImplementation((file: string, args: string[]) => { - if (file === 'ps' && args.join(' ') === `-o ppid= -p ${process.pid}`) { - return '1234\n'; - } - - if (file === 'ps' && args.join(' ') === '-o tty= -p 1234') { - return '??\n'; + if (file === 'ps' && args.join(' ') === `-o ppid=,tty= -p ${process.pid}`) { + return '1234 ?\n'; } - if (file === 'ps' && args.join(' ') === '-o ppid= -p 1234') { - return '5678\n'; + if (file === 'ps' && args.join(' ') === '-o ppid=,tty= -p 1234') { + return '5678 ??\n'; } - if (file === 'ps' && args.join(' ') === '-o tty= -p 5678') { - return 'ttys010\n'; + if (file === 'ps' && args.join(' ') === '-o ppid=,tty= -p 5678') { + return '1 ttys010\n'; } if (file === 'stty' && args.join(' ') === '-F /dev/ttys010 size') { @@ -232,12 +211,8 @@ describe('terminal utils', () => { process.env.CCSTATUSLINE_WIDTH = '0'; mockExecFileSync.mockImplementation((file: string, args: string[]) => { - if (file === 'ps' && args.join(' ') === `-o ppid= -p ${process.pid}`) { - return '1234\n'; - } - - if (file === 'ps' && args.join(' ') === '-o tty= -p 1234') { - return 'ttys001\n'; + if (file === 'ps' && args.join(' ') === `-o ppid=,tty= -p ${process.pid}`) { + return '1234 ttys001\n'; } if (file === 'stty' && args.join(' ') === '-F /dev/ttys001 size') { @@ -255,12 +230,8 @@ describe('terminal utils', () => { process.env.CCSTATUSLINE_WIDTH = 'wide'; mockExecFileSync.mockImplementation((file: string, args: string[]) => { - if (file === 'ps' && args.join(' ') === `-o ppid= -p ${process.pid}`) { - return '1234\n'; - } - - if (file === 'ps' && args.join(' ') === '-o tty= -p 1234') { - return 'ttys001\n'; + if (file === 'ps' && args.join(' ') === `-o ppid=,tty= -p ${process.pid}`) { + return '1234 ttys001\n'; } if (file === 'stty' && args.join(' ') === '-F /dev/ttys001 size') { @@ -293,12 +264,8 @@ describe('terminal utils', () => { it('probes only once across repeated calls when a width is found', () => { pinPosixPlatform(); mockExecFileSync.mockImplementation((file: string, args: string[]) => { - if (file === 'ps' && args.join(' ') === `-o ppid= -p ${process.pid}`) { - return '1234\n'; - } - - if (file === 'ps' && args.join(' ') === '-o tty= -p 1234') { - return 'ttys001\n'; + if (file === 'ps' && args.join(' ') === `-o ppid=,tty= -p ${process.pid}`) { + return '1234 ttys001\n'; } if (file === 'stty' && args.join(' ') === '-F /dev/ttys001 size') { @@ -313,7 +280,7 @@ describe('terminal utils', () => { expect(canDetectTerminalWidth()).toBe(true); const ppidProbes = mockExecFileSync.mock.calls.filter( - call => call[0] === 'ps' && Array.isArray(call[1]) && (call[1])[1] === 'ppid=' + call => call[0] === 'ps' && Array.isArray(call[1]) && (call[1])[1] === 'ppid=,tty=' ); expect(ppidProbes).toHaveLength(1); }); @@ -333,7 +300,7 @@ describe('terminal utils', () => { expect(canDetectTerminalWidth()).toBe(false); const ppidProbes = mockExecFileSync.mock.calls.filter( - call => call[0] === 'ps' && Array.isArray(call[1]) && (call[1])[1] === 'ppid=' + call => call[0] === 'ps' && Array.isArray(call[1]) && (call[1])[1] === 'ppid=,tty=' ); expect(ppidProbes).toHaveLength(1); }); @@ -434,12 +401,8 @@ describe('terminal utils', () => { it('never persists a discovered numeric width to the L2 cache', () => { pinPosixPlatform(); mockExecFileSync.mockImplementation((file: string, args: string[]) => { - if (file === 'ps' && args.join(' ') === `-o ppid= -p ${process.pid}`) { - return '1234\n'; - } - - if (file === 'ps' && args.join(' ') === '-o tty= -p 1234') { - return 'ttys001\n'; + if (file === 'ps' && args.join(' ') === `-o ppid=,tty= -p ${process.pid}`) { + return '1234 ttys001\n'; } if (file === 'stty' && args.join(' ') === '-F /dev/ttys001 size') { diff --git a/src/utils/claude-service-status.ts b/src/utils/claude-service-status.ts index 5a31aef15..174837ead 100644 --- a/src/utils/claude-service-status.ts +++ b/src/utils/claude-service-status.ts @@ -1,6 +1,5 @@ import * as fs from 'fs'; import * as https from 'https'; -import { HttpsProxyAgent } from 'https-proxy-agent'; import * as os from 'os'; import * as path from 'path'; import { z } from 'zod'; @@ -282,55 +281,76 @@ type StatusPageRequestFn = ( const requestStatusPage: StatusPageRequestFn = (options, onResponse) => https.request(options, onResponse); +async function getStatusPageRequestOptions(): Promise { + const proxyUrl = getStatusPageProxyUrl(); + + try { + let agent: https.RequestOptions['agent'] | undefined; + if (proxyUrl) { + // Loaded on demand: the agent (and its transitive module graph) is + // only needed when a proxy is actually configured, and the + // statusline entry point re-runs on every repaint (#397). + const { HttpsProxyAgent: ProxyAgent } = await import('https-proxy-agent'); + agent = new ProxyAgent(proxyUrl); + } + + return { + hostname: STATUS_HOST, + path: '', + method: 'GET', + timeout: STATUS_TIMEOUT_MS, + ...(agent ? { agent } : {}) + }; + } catch { + return null; + } +} + function fetchStatusPagePath( pathName: string, requestFn: StatusPageRequestFn = requestStatusPage ): Promise { - return new Promise((resolve) => { - let settled = false; + return getStatusPageRequestOptions().then((baseOptions) => { + if (!baseOptions) { + return null; + } - const finish = (value: string | null) => { - if (settled) { - return; - } - settled = true; - resolve(value); - }; + return new Promise((resolve) => { + let settled = false; - let requestOptions: https.RequestOptions; - try { - const proxyUrl = getStatusPageProxyUrl(); - requestOptions = { - hostname: STATUS_HOST, - path: pathName, - method: 'GET', - timeout: STATUS_TIMEOUT_MS, - ...(proxyUrl ? { agent: new HttpsProxyAgent(proxyUrl) } : {}) + const finish = (value: string | null) => { + if (settled) { + return; + } + settled = true; + resolve(value); }; - } catch { - finish(null); - return; - } - const request = requestFn(requestOptions, (response) => { - let data = ''; - response.setEncoding('utf8'); - response.on('data', (chunk: string) => { - data += chunk; - }); - response.on('end', () => { - finish(response.statusCode === 200 && data ? data : null); + const requestOptions: https.RequestOptions = { + ...baseOptions, + path: pathName + }; + + const request = requestFn(requestOptions, (response) => { + let data = ''; + response.setEncoding('utf8'); + response.on('data', (chunk: string) => { + data += chunk; + }); + response.on('end', () => { + finish(response.statusCode === 200 && data ? data : null); + }); + response.on('aborted', () => { finish(null); }); + response.on('error', () => { finish(null); }); }); - response.on('aborted', () => { finish(null); }); - response.on('error', () => { finish(null); }); - }); - request.on('error', () => { finish(null); }); - request.on('timeout', () => { - request.destroy(); - finish(null); + request.on('error', () => { finish(null); }); + request.on('timeout', () => { + request.destroy(); + finish(null); + }); + request.end(); }); - request.end(); }); } diff --git a/src/utils/terminal.ts b/src/utils/terminal.ts index 5faf9ae7d..2f7b45893 100644 --- a/src/utils/terminal.ts +++ b/src/utils/terminal.ts @@ -57,19 +57,18 @@ function probeTerminalWidth(): number | null { // find the shell process that owns the real PTY. let pid = process.pid; for (let depth = 0; depth < 8; depth += 1) { - const parentPid = getParentProcessId(pid); - if (parentPid === null) { + const ancestor = getProcessAncestorInfo(pid); + if (ancestor.parentPid === null) { break; } - pid = parentPid; + pid = ancestor.parentPid; - const tty = getTTYForProcess(pid); - if (tty === null) { + if (ancestor.tty === null) { continue; } - const width = getWidthForTTY(tty); + const width = getWidthForTTY(ancestor.tty); if (width !== null) { return width; } @@ -100,35 +99,33 @@ function parsePositiveInteger(value: string): number | null { return parsed; } -function getParentProcessId(pid: number): number | null { - try { - const parentPidOutput = execFileSync('ps', ['-o', 'ppid=', '-p', String(pid)], { - encoding: 'utf8', - stdio: ['pipe', 'pipe', 'ignore'], - windowsHide: true - }).trim(); - - return parsePositiveInteger(parentPidOutput); - } catch { - return null; - } +interface ProcessAncestorInfo { + parentPid: number | null; + tty: string | null; } -function getTTYForProcess(pid: number): string | null { +function getProcessAncestorInfo(pid: number): ProcessAncestorInfo { + // One ps spawn answers both questions of the ancestor walk: the parent pid + // and the controlling TTY. The probe re-runs per render on macOS (numeric + // widths are deliberately not persisted across processes), and under + // several concurrent sessions each saved subprocess is saved per repaint + // (see #397). try { - const tty = execFileSync('ps', ['-o', 'tty=', '-p', String(pid)], { + const output = execFileSync('ps', ['-o', 'ppid=,tty=', '-p', String(pid)], { encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'], windowsHide: true - }).replace(/\s+/g, ''); + }).trim(); - if (!tty || tty === '??' || tty === '?') { - return null; - } + const fields = output.split(/\s+/); + const tty = fields[1] ?? ''; - return tty; + return { + parentPid: parsePositiveInteger(fields[0] ?? ''), + tty: !tty || tty === '?' || tty === '??' ? null : tty + }; } catch { - return null; + return { parentPid: null, tty: null }; } } diff --git a/src/utils/usage-fetch.ts b/src/utils/usage-fetch.ts index 964ba8be4..315212dde 100644 --- a/src/utils/usage-fetch.ts +++ b/src/utils/usage-fetch.ts @@ -2,7 +2,7 @@ import { execFileSync } from 'child_process'; import { createHash } from 'crypto'; import * as fs from 'fs'; import * as https from 'https'; -import { HttpsProxyAgent } from 'https-proxy-agent'; +import type { HttpsProxyAgent } from 'https-proxy-agent'; import * as os from 'os'; import * as path from 'path'; import { z } from 'zod'; @@ -719,10 +719,19 @@ function getUsageApiProxyUrl(): string | null { return proxyUrl ?? null; } -function getUsageApiRequestOptions(token: string): https.RequestOptions | null { +async function getUsageApiRequestOptions(token: string): Promise { const proxyUrl = getUsageApiProxyUrl(); try { + let agent: InstanceType | undefined; + if (proxyUrl) { + // Loaded on demand: the agent (and its transitive module graph) only + // matters when a proxy is actually configured, and the statusline + // entry point is re-run on every repaint (#397). + const { HttpsProxyAgent: ProxyAgent } = await import('https-proxy-agent'); + agent = new ProxyAgent(proxyUrl); + } + return { hostname: USAGE_API_HOST, path: USAGE_API_PATH, @@ -732,7 +741,7 @@ function getUsageApiRequestOptions(token: string): https.RequestOptions | null { 'anthropic-beta': 'oauth-2025-04-20' }, timeout: USAGE_API_TIMEOUT_MS, - ...(proxyUrl ? { agent: new HttpsProxyAgent(proxyUrl) } : {}) + ...(agent ? { agent } : {}) }; } catch { return null; @@ -740,6 +749,11 @@ function getUsageApiRequestOptions(token: string): https.RequestOptions | null { } async function fetchFromUsageApi(token: string): Promise { + const requestOptions = await getUsageApiRequestOptions(token); + if (!requestOptions) { + return { kind: 'error' }; + } + return new Promise((resolve) => { let settled = false; @@ -751,12 +765,6 @@ async function fetchFromUsageApi(token: string): Promise { resolve(value); }; - const requestOptions = getUsageApiRequestOptions(token); - if (!requestOptions) { - finish({ kind: 'error' }); - return; - } - const request = https.request(requestOptions, (response) => { let data = ''; response.setEncoding('utf8'); From 1177422d234a2ce824a5b2e3c49fa067e620fe2a Mon Sep 17 00:00:00 2001 From: axisrow Date: Fri, 25 Sep 2026 14:59:52 +0800 Subject: [PATCH 2/3] fix(review): clarify width-walk invariant and test status-page proxy wiring - terminal: check the ancestor TTY before reassigning pid so each loop iteration reads as "one ps answer, one pid"; no call-order change - claude-service-status: cover the lazy proxy-agent branch with tests (agent attached when HTTPS_PROXY is set, absent otherwise), matching the usage-fetch proxy coverage Co-Authored-By: Claude Code --- .../__tests__/claude-service-status.test.ts | 49 +++++++++++++++++++ src/utils/terminal.ts | 14 +++--- 2 files changed, 55 insertions(+), 8 deletions(-) diff --git a/src/utils/__tests__/claude-service-status.test.ts b/src/utils/__tests__/claude-service-status.test.ts index 5818c662f..a48376ce7 100644 --- a/src/utils/__tests__/claude-service-status.test.ts +++ b/src/utils/__tests__/claude-service-status.test.ts @@ -1,5 +1,6 @@ import { EventEmitter } from 'events'; import { + afterEach, describe, expect, it @@ -182,6 +183,54 @@ describe('status page response handling', () => { }); }); +describe('proxy agent wiring', () => { + const ORIGINAL_HTTPS_PROXY = process.env.HTTPS_PROXY; + + afterEach(() => { + if (ORIGINAL_HTTPS_PROXY === undefined) { + delete process.env.HTTPS_PROXY; + } else { + process.env.HTTPS_PROXY = ORIGINAL_HTTPS_PROXY; + } + }); + + function captureRequest(captured: { agent: unknown }): StatusPageRequestFn { + return (options, onResponse) => { + captured.agent = (options as { agent?: unknown }).agent; + const response = Object.assign(new EventEmitter(), { + statusCode: 200, + setEncoding: () => undefined + }); + const request = Object.assign(new EventEmitter(), { + destroy: () => undefined, + end() { + onResponse(response); + response.emit('data', 'status body'); + response.emit('end'); + } + }); + + return request; + }; + } + + it('attaches a proxy agent when HTTPS_PROXY is set', async () => { + process.env.HTTPS_PROXY = 'http://proxy.example.test:8080'; + const captured: { agent: unknown } = { agent: undefined }; + + await expect(__testing.fetchStatusPagePath('/test', captureRequest(captured))).resolves.toBe('status body'); + expect(captured.agent).toBeTruthy(); + }); + + it('sends no proxy agent without HTTPS_PROXY', async () => { + delete process.env.HTTPS_PROXY; + const captured: { agent: unknown } = { agent: undefined }; + + await expect(__testing.fetchStatusPagePath('/test', captureRequest(captured))).resolves.toBe('status body'); + expect(captured.agent).toBeUndefined(); + }); +}); + describe('claude-status prefetch predicates', () => { it('detects claude-status widgets in configured lines', () => { expect(hasClaudeStatusWidgets([[{ id: '1', type: 'model' }]])).toBe(false); diff --git a/src/utils/terminal.ts b/src/utils/terminal.ts index 2f7b45893..4b760fb28 100644 --- a/src/utils/terminal.ts +++ b/src/utils/terminal.ts @@ -62,16 +62,14 @@ function probeTerminalWidth(): number | null { break; } - pid = ancestor.parentPid; - - if (ancestor.tty === null) { - continue; + if (ancestor.tty !== null) { + const width = getWidthForTTY(ancestor.tty); + if (width !== null) { + return width; + } } - const width = getWidthForTTY(ancestor.tty); - if (width !== null) { - return width; - } + pid = ancestor.parentPid; } // Fallback: try tput cols which might work in some environments From 0fc829c970f9e18b878e37af8321c99b4b32d75c Mon Sep 17 00:00:00 2001 From: axisrow Date: Fri, 25 Sep 2026 20:14:01 +0800 Subject: [PATCH 3/3] fix(terminal): restore walk parity and BSD-safe ps format per verification - ps: use two -o flags instead of the comma list; FreeBSD's parser treats everything after the first '=' as one header, which collapsed the combined form into a single bogus column and dropped the probe to the tput fallback. Still one spawn per ancestor. - walk: start at process.ppid and read each generation's TTY before terminating on its PPID, restoring the old two-spawn walk's generation coverage (1..8) and keeping a dead-end PPID from hiding a valid width on the same ps row. Known narrowing vs the old sequence: if the combined lookup fails entirely, the walk stops (the old two-call flow could still continue on the separately obtained PPID). Co-Authored-By: Claude Code --- src/utils/__tests__/terminal.test.ts | 53 +++++++++++++++++++--------- src/utils/terminal.ts | 21 +++++++---- 2 files changed, 50 insertions(+), 24 deletions(-) diff --git a/src/utils/__tests__/terminal.test.ts b/src/utils/__tests__/terminal.test.ts index d5f03697a..80e786eec 100644 --- a/src/utils/__tests__/terminal.test.ts +++ b/src/utils/__tests__/terminal.test.ts @@ -66,7 +66,7 @@ describe('terminal utils', () => { it('returns width from the immediate parent tty when available', () => { pinPosixPlatform(); mockExecFileSync.mockImplementation((file: string, args: string[]) => { - if (file === 'ps' && args.join(' ') === `-o ppid=,tty= -p ${process.pid}`) { + if (file === 'ps' && args.join(' ') === `-o ppid= -o tty= -p ${process.ppid}`) { return '1234 ttys001\n'; } @@ -79,7 +79,7 @@ describe('terminal utils', () => { expect(getTerminalWidth()).toBe(120); expect(mockExecFileSync.mock.calls.map(([file, args]) => `${file as string} ${(args as string[]).join(' ')}`)).toEqual([ - `ps -o ppid=,tty= -p ${process.pid}`, + `ps -o ppid= -o tty= -p ${process.ppid}`, 'stty -F /dev/ttys001 size' ]); }); @@ -87,15 +87,15 @@ describe('terminal utils', () => { it('walks ancestor processes until it finds a valid tty', () => { pinPosixPlatform(); mockExecFileSync.mockImplementation((file: string, args: string[]) => { - if (file === 'ps' && args.join(' ') === `-o ppid=,tty= -p ${process.pid}`) { + if (file === 'ps' && args.join(' ') === `-o ppid= -o tty= -p ${process.ppid}`) { return '1234 ?\n'; } - if (file === 'ps' && args.join(' ') === '-o ppid=,tty= -p 1234') { + if (file === 'ps' && args.join(' ') === '-o ppid= -o tty= -p 1234') { return '5678 ??\n'; } - if (file === 'ps' && args.join(' ') === '-o ppid=,tty= -p 5678') { + if (file === 'ps' && args.join(' ') === '-o ppid= -o tty= -p 5678') { return '1 ttys009\n'; } @@ -109,11 +109,30 @@ describe('terminal utils', () => { expect(getTerminalWidth()).toBe(104); }); + it('reads the current generation TTY even when its PPID ends the walk', () => { + pinPosixPlatform(); + // A PPID of 0 terminates the walk after this row, not before it: + // the parent's own width must still win over the tput fallback. + mockExecFileSync.mockImplementation((file: string, args: string[]) => { + if (file === 'ps' && args.join(' ') === `-o ppid= -o tty= -p ${process.ppid}`) { + return '0 ttys001\n'; + } + + if (file === 'stty' && args.join(' ') === '-F /dev/ttys001 size') { + return '24 120\n'; + } + + throw new Error(`Unexpected command: ${file} ${args.join(' ')}`); + }); + + expect(getTerminalWidth()).toBe(120); + }); + it('falls back through stty variants when the first form returns no value', () => { pinPosixPlatform(); // Simulates BSD/macOS, where `stty -F` exits with an error; `stty -f` succeeds. mockExecFileSync.mockImplementation((file: string, args: string[]) => { - if (file === 'ps' && args.join(' ') === `-o ppid=,tty= -p ${process.pid}`) { + if (file === 'ps' && args.join(' ') === `-o ppid= -o tty= -p ${process.ppid}`) { return '1234 ttys003\n'; } @@ -144,11 +163,11 @@ describe('terminal utils', () => { it('returns null when ancestor and fallback probes all fail', () => { pinPosixPlatform(); mockExecFileSync.mockImplementation((file: string, args: string[]) => { - if (file === 'ps' && args.join(' ') === `-o ppid=,tty= -p ${process.pid}`) { + if (file === 'ps' && args.join(' ') === `-o ppid= -o tty= -p ${process.ppid}`) { return '1234 ttys001\n'; } - if (file === 'ps' && args.join(' ') === '-o ppid=,tty= -p 1234') { + if (file === 'ps' && args.join(' ') === '-o ppid= -o tty= -p 1234') { return '0 ttys001\n'; } @@ -169,15 +188,15 @@ describe('terminal utils', () => { it('detects availability when an ancestor tty probe succeeds', () => { pinPosixPlatform(); mockExecFileSync.mockImplementation((file: string, args: string[]) => { - if (file === 'ps' && args.join(' ') === `-o ppid=,tty= -p ${process.pid}`) { + if (file === 'ps' && args.join(' ') === `-o ppid= -o tty= -p ${process.ppid}`) { return '1234 ?\n'; } - if (file === 'ps' && args.join(' ') === '-o ppid=,tty= -p 1234') { + if (file === 'ps' && args.join(' ') === '-o ppid= -o tty= -p 1234') { return '5678 ??\n'; } - if (file === 'ps' && args.join(' ') === '-o ppid=,tty= -p 5678') { + if (file === 'ps' && args.join(' ') === '-o ppid= -o tty= -p 5678') { return '1 ttys010\n'; } @@ -211,7 +230,7 @@ describe('terminal utils', () => { process.env.CCSTATUSLINE_WIDTH = '0'; mockExecFileSync.mockImplementation((file: string, args: string[]) => { - if (file === 'ps' && args.join(' ') === `-o ppid=,tty= -p ${process.pid}`) { + if (file === 'ps' && args.join(' ') === `-o ppid= -o tty= -p ${process.ppid}`) { return '1234 ttys001\n'; } @@ -230,7 +249,7 @@ describe('terminal utils', () => { process.env.CCSTATUSLINE_WIDTH = 'wide'; mockExecFileSync.mockImplementation((file: string, args: string[]) => { - if (file === 'ps' && args.join(' ') === `-o ppid=,tty= -p ${process.pid}`) { + if (file === 'ps' && args.join(' ') === `-o ppid= -o tty= -p ${process.ppid}`) { return '1234 ttys001\n'; } @@ -264,7 +283,7 @@ describe('terminal utils', () => { it('probes only once across repeated calls when a width is found', () => { pinPosixPlatform(); mockExecFileSync.mockImplementation((file: string, args: string[]) => { - if (file === 'ps' && args.join(' ') === `-o ppid=,tty= -p ${process.pid}`) { + if (file === 'ps' && args.join(' ') === `-o ppid= -o tty= -p ${process.ppid}`) { return '1234 ttys001\n'; } @@ -280,7 +299,7 @@ describe('terminal utils', () => { expect(canDetectTerminalWidth()).toBe(true); const ppidProbes = mockExecFileSync.mock.calls.filter( - call => call[0] === 'ps' && Array.isArray(call[1]) && (call[1])[1] === 'ppid=,tty=' + call => call[0] === 'ps' && Array.isArray(call[1]) && (call[1])[1] === 'ppid=' ); expect(ppidProbes).toHaveLength(1); }); @@ -300,7 +319,7 @@ describe('terminal utils', () => { expect(canDetectTerminalWidth()).toBe(false); const ppidProbes = mockExecFileSync.mock.calls.filter( - call => call[0] === 'ps' && Array.isArray(call[1]) && (call[1])[1] === 'ppid=,tty=' + call => call[0] === 'ps' && Array.isArray(call[1]) && (call[1])[1] === 'ppid=' ); expect(ppidProbes).toHaveLength(1); }); @@ -401,7 +420,7 @@ describe('terminal utils', () => { it('never persists a discovered numeric width to the L2 cache', () => { pinPosixPlatform(); mockExecFileSync.mockImplementation((file: string, args: string[]) => { - if (file === 'ps' && args.join(' ') === `-o ppid=,tty= -p ${process.pid}`) { + if (file === 'ps' && args.join(' ') === `-o ppid= -o tty= -p ${process.ppid}`) { return '1234 ttys001\n'; } diff --git a/src/utils/terminal.ts b/src/utils/terminal.ts index 4b760fb28..eb8c4d50b 100644 --- a/src/utils/terminal.ts +++ b/src/utils/terminal.ts @@ -54,14 +54,15 @@ function probeTerminalWidth(): number | null { // Claude Code can spawn ccstatusline with piped stdio, leaving the immediate // parent process without a controlling TTY. Walk up a few ancestors until we - // find the shell process that owns the real PTY. - let pid = process.pid; + // find the shell process that owns the real PTY. process.ppid starts the walk + // at generation 1 for free, matching the old two-spawn walk's generation + // coverage (1..8). + let pid = process.ppid; for (let depth = 0; depth < 8; depth += 1) { const ancestor = getProcessAncestorInfo(pid); - if (ancestor.parentPid === null) { - break; - } + // Try this generation's TTY even when the PPID looks terminal: a PPID of + // 0 must not hide a perfectly good width on the same ps row. if (ancestor.tty !== null) { const width = getWidthForTTY(ancestor.tty); if (width !== null) { @@ -69,6 +70,10 @@ function probeTerminalWidth(): number | null { } } + if (ancestor.parentPid === null) { + break; + } + pid = ancestor.parentPid; } @@ -107,9 +112,11 @@ function getProcessAncestorInfo(pid: number): ProcessAncestorInfo { // and the controlling TTY. The probe re-runs per render on macOS (numeric // widths are deliberately not persisted across processes), and under // several concurrent sessions each saved subprocess is saved per repaint - // (see #397). + // (see #397). Two -o flags rather than one comma list: FreeBSD's parser + // treats everything after the first '=' as a single header, so the comma + // form collapses into one bogus column. try { - const output = execFileSync('ps', ['-o', 'ppid=,tty=', '-p', String(pid)], { + const output = execFileSync('ps', ['-o', 'ppid=', '-o', 'tty=', '-p', String(pid)], { encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'], windowsHide: true