Skip to content
Open
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
30 changes: 1 addition & 29 deletions src/ccstatusline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import {
getWidgetSpeedWindowSeconds,
isWidgetSpeedWindowEnabled
} from './utils/speed-window';
import { readStdin } from './utils/stdin';
import {
getPackageVersion,
getTerminalWidth
Expand All @@ -57,35 +58,6 @@ function hasSessionDurationInStatusJson(data: StatusJSON): boolean {
return typeof durationMs === 'number' && Number.isFinite(durationMs) && durationMs >= 0;
}

async function readStdin(): Promise<string | null> {
// Check if stdin is a TTY (terminal) - if it is, there's no piped data
if (process.stdin.isTTY) {
return null;
}

const chunks: string[] = [];

try {
// Use Node.js compatible approach
if (typeof Bun !== 'undefined') {
// Bun environment
const decoder = new TextDecoder();
for await (const chunk of Bun.stdin.stream()) {
chunks.push(decoder.decode(chunk));
}
} else {
// Node.js environment
process.stdin.setEncoding('utf8');
for await (const chunk of process.stdin) {
chunks.push(chunk as string);
}
}
return chunks.join('');
} catch {
return null;
}
}

async function ensureWindowsUtf8CodePage() {
if (process.platform !== 'win32') {
return;
Expand Down
65 changes: 65 additions & 0 deletions src/utils/__tests__/stdin.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { Readable } from 'node:stream';
import {
afterEach,
beforeEach,
describe,
expect,
it,
vi
} from 'vitest';

import { readStdin } from '../stdin';

const originalStdin = process.stdin;
const originalTimeout = process.env.CCSTATUSLINE_STDIN_TIMEOUT_MS;

function setStdin(stream: Readable & { isTTY?: boolean }) {
Object.defineProperty(process, 'stdin', {
value: stream,
configurable: true
});
}

describe('readStdin', () => {
beforeEach(() => {
process.env.CCSTATUSLINE_STDIN_TIMEOUT_MS = '50';
});

afterEach(() => {
Object.defineProperty(process, 'stdin', {
value: originalStdin,
configurable: true
});

if (originalTimeout === undefined) {
delete process.env.CCSTATUSLINE_STDIN_TIMEOUT_MS;
} else {
process.env.CCSTATUSLINE_STDIN_TIMEOUT_MS = originalTimeout;
}

vi.useRealTimers();
});

it('returns null when stdin is a TTY', async () => {
const stream = Object.assign(Readable.from([]), { isTTY: true });
setStdin(stream);

await expect(readStdin()).resolves.toBeNull();
});

it('reads the payload when the writer closes the stream', async () => {
const stream = Object.assign(Readable.from(['{"session_id":"abc"}']), { isTTY: false });
setStdin(stream);

await expect(readStdin()).resolves.toBe('{"session_id":"abc"}');
});

it('resolves with what arrived when EOF never comes', async () => {
const stream = Object.assign(new Readable({ read() { /* never pushes EOF */ } }), { isTTY: false });
stream.push('{"session_id":"abc"}');
setStdin(stream);

// Without the timeout this would hang and the process would outlive the render.
await expect(readStdin()).resolves.toBe('{"session_id":"abc"}');
});
});
65 changes: 65 additions & 0 deletions src/utils/stdin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/**
* Default time to wait for the status JSON before giving up on EOF.
* Override with CCSTATUSLINE_STDIN_TIMEOUT_MS.
*/
const DEFAULT_STDIN_TIMEOUT_MS = 5000;

function getStdinTimeoutMs(): number {
const raw = Number(process.env.CCSTATUSLINE_STDIN_TIMEOUT_MS);
return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_STDIN_TIMEOUT_MS;
}

/**
* Reads the piped status JSON.
*
* A host that writes the payload but never closes the write end leaves the async
* iteration below suspended forever, and the process outlives the render it was
* spawned for. The timeout bounds that: whatever arrived is still worth rendering,
* because the payload is written in one shot, so a timeout means the EOF is
* missing rather than the data.
*/
export async function readStdin(): Promise<string | null> {
// Check if stdin is a TTY (terminal) - if it is, there's no piped data
if (process.stdin.isTTY) {
return null;
}

const chunks: string[] = [];

const read = async (): Promise<string> => {
// Bun exposes the same Node-compatible process.stdin stream. Reading
// through it keeps this function testable and avoids maintaining two
// subtly different input paths.
process.stdin.setEncoding('utf8');
for await (const chunk of process.stdin) {
chunks.push(chunk as string);
}
return chunks.join('');
};

let timer: ReturnType<typeof setTimeout> | undefined;

try {
return await Promise.race([
read(),
new Promise<string>((resolve) => {
timer = setTimeout(() => {
resolve(chunks.join(''));
}, getStdinTimeoutMs());
})
]);
} catch {
return null;
} finally {
if (timer) {
clearTimeout(timer);
}

// The reader can still hold the stream open after the race settles.
process.stdin.pause();
const unref = Reflect.get(process.stdin, 'unref') as unknown;
if (typeof unref === 'function') {
unref.call(process.stdin);
}
}
}