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
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,13 @@

## 🆕 Recent Updates

### Unreleased - Terminal width probing no longer spawns 100+ processes per render

- **⚡ Zero-subprocess width detection** - On Linux the terminal width is now read from `/proc` and `TIOCGWINSZ` instead of shelling out. A render went from **122 spawned processes to 1** (`node` itself) and from **4.37 CPU-seconds to 1.08** on the benchmark payload. macOS/BSD keep the portable `ps`/`stty`/`tput` walk, now invoked without a `/bin/sh` wrapper.
- **🎯 Correct width, not just faster** - The old `tput cols` fallback reported its no-TTY default of 80 columns on terminals that were actually 209 wide. The new probe finds the real pty and reports the true width.
- **♻️ The width is cached** - Claude Code spawns the status line without a TTY, so the probe returned `null`; because callers read it as `context.terminalWidth ?? getTerminalWidth()`, that `null` re-ran the whole ancestor walk **once per configured line, on every render**. The result (including a `null`) is now memoized in-process and shared across renders and sessions via `~/.cache/ccstatusline/terminal-width.json`.
- **⏱️ `terminalWidthCacheTtlSeconds`** - New setting, default `5`, range 0–300. Controls how long a probed width is reused; resize your terminal and the status line corrects itself within this many seconds. Set to `0` to probe on every render. (Note: unlike `gitCacheTtlSeconds`, where `0` means "never expire", `0` here disables the cache.)

### v2.2.22 - v2.2.23 - Powerline flex mode, layout controls, composable metrics, and safer config

![Powerline Flex Mode](https://raw.githubusercontent.com/sirmalloc/ccstatusline/main/screenshots/powerline-flex.png)
Expand Down
8 changes: 6 additions & 2 deletions src/ccstatusline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,10 +167,14 @@ async function renderMultipleLines(data: StatusJSON) {
sessionDuration,
skillsMetrics,
compactionData,
terminalWidth: getTerminalWidth(),
terminalWidth: getTerminalWidth({
sessionId: data.session_id,
ttlSeconds: settings.terminalWidthCacheTtlSeconds
}),
isPreview: false,
minimalist: settings.minimalistMode,
gitCacheTtlSeconds: settings.gitCacheTtlSeconds
gitCacheTtlSeconds: settings.gitCacheTtlSeconds,
terminalWidthCacheTtlSeconds: settings.terminalWidthCacheTtlSeconds
};

// Always pre-render all widgets once (for efficiency)
Expand Down
1 change: 1 addition & 0 deletions src/types/RenderContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export interface RenderContext {
isPreview?: boolean;
minimalist?: boolean;
gitCacheTtlSeconds?: number;
terminalWidthCacheTtlSeconds?: number;
lineIndex?: number; // Index of the current line being rendered (for theme cycling)
globalSeparatorIndex?: number; // Global separator index that continues across lines

Expand Down
6 changes: 6 additions & 0 deletions src/types/Settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,12 @@ export const SettingsSchema = z.object({
overrideForegroundColor: z.string().optional(),
globalBold: z.boolean().default(false),
gitCacheTtlSeconds: z.number().min(0).max(60).default(5),
// How long a probed terminal width is reused, in seconds. The probe is the
// most expensive thing a render does, so the result is shared across renders
// and sessions via ~/.cache/ccstatusline/terminal-width.json.
// NOTE: 0 disables the cache (always probe). This deliberately differs from
// gitCacheTtlSeconds above, where 0 means "never expire".
terminalWidthCacheTtlSeconds: z.number().min(0).max(300).default(5),
minimalistMode: z.boolean().default(false),
powerline: PowerlineConfigSchema.default({
enabled: false,
Expand Down
107 changes: 107 additions & 0 deletions src/utils/__tests__/terminal-native.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import {
describe,
expect,
it
} from 'vitest';

import type { NativeProbeDeps } from '../terminal-native';
import {
parsePpidFromStat,
probeWidthNative
} from '../terminal-native';

// A /proc/<pid>/stat line whose comm field contains spaces AND a close-paren.
// Naive `split(' ')[3]` gets this wrong; fields must be read after the LAST ')'.
const TRICKY_STAT = '4242 (my ) weird proc) S 1234 4242 4242 0 -1 4194304 100 0 0 0 5 3 0 0 20 0 1 0 999 0 0';

function makeDeps(overrides: Partial<NativeProbeDeps> = {}): NativeProbeDeps {
return {
platform: 'linux',
readFileSync: () => { throw new Error('unexpected readFileSync'); },
readlinkSync: () => { throw new Error('unexpected readlinkSync'); },
openSync: () => 7,
closeSync: () => undefined,
isatty: () => true,
getColumns: () => 209,
...overrides
};
}

describe('parsePpidFromStat', () => {
it('parses the ppid when comm contains spaces and parens', () => {
expect(parsePpidFromStat(TRICKY_STAT)).toBe(1234);
});

it('returns null on garbage', () => {
expect(parsePpidFromStat('not a stat line')).toBeNull();
});
});

describe('probeWidthNative', () => {
it('returns null on non-linux platforms', () => {
expect(probeWidthNative(makeDeps({ platform: 'darwin' }))).toBeNull();
});

it('walks ancestors and returns the width of the first tty found', () => {
const deps = makeDeps({
// self -> 4242 -> 1234. Only 1234 owns a pty.
readFileSync: (p: string) => {
if (p === `/proc/${process.pid}/stat`) {
return '1 (node) S 4242 1 1 0 -1 0 0 0 0 0 0 0 0 0 20 0 1 0 1 0 0';
}

if (p === '/proc/4242/stat') {
return TRICKY_STAT;
}

throw new Error(`no such stat: ${p}`);
},
readlinkSync: (p: string) => {
if (p === '/proc/1234/fd/0') {
return '/dev/pts/7';
}

throw new Error(`not a tty fd: ${p}`);
},
getColumns: () => 209
});

expect(probeWidthNative(deps)).toBe(209);
});

it('returns null when no ancestor owns a tty', () => {
const deps = makeDeps({
readFileSync: () => '1 (node) S 0 1 1 0 -1 0 0 0 0 0 0 0 0 0 20 0 1 0 1 0 0',
readlinkSync: () => { throw new Error('ENOENT'); }
});

expect(probeWidthNative(deps)).toBeNull();
});

it('returns null (and does not throw) when the device is not a tty', () => {
const deps = makeDeps({
readFileSync: (p: string) => (p === `/proc/${process.pid}/stat`
? '1 (node) S 1234 1 1 0 -1 0 0 0 0 0 0 0 0 0 20 0 1 0 1 0 0'
: (() => { throw new Error('stop'); })()),
readlinkSync: () => '/dev/pts/7',
isatty: () => false
});

expect(probeWidthNative(deps)).toBeNull();
});

it('closes the fd even when getColumns throws', () => {
const closed: number[] = [];
const deps = makeDeps({
readFileSync: (p: string) => (p === `/proc/${process.pid}/stat`
? '1 (node) S 1234 1 1 0 -1 0 0 0 0 0 0 0 0 0 20 0 1 0 1 0 0'
: (() => { throw new Error('stop'); })()),
readlinkSync: () => '/dev/pts/7',
closeSync: (fd: number) => { closed.push(fd); },
getColumns: () => { throw new Error('ioctl failed'); }
});

expect(probeWidthNative(deps)).toBeNull();
expect(closed).toEqual([7]);
});
});
118 changes: 118 additions & 0 deletions src/utils/__tests__/terminal-width-cache.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import {
beforeEach,
describe,
expect,
it
} from 'vitest';

import type { WidthCacheDeps } from '../terminal-width-cache';
import {
readCachedWidth,
writeCachedWidth
} from '../terminal-width-cache';

const CACHE_PATH = '/tmp/test-terminal-width.json';

function makeDeps(initial: string | null, now = 1_000_000): WidthCacheDeps & { files: Map<string, string> } {
const files = new Map<string, string>();
if (initial !== null) {
files.set(CACHE_PATH, initial);
}

return {
files,
cachePath: CACHE_PATH,
now: () => now,
mkdirSync: () => undefined,
readFileSync: (p: string) => {
const content = files.get(p);
if (content === undefined) {
throw new Error('ENOENT');
}

return content;
},
writeFileSync: (p: string, data: string) => { files.set(p, data); },
renameSync: (from: string, to: string) => {
const data = files.get(from);
if (data === undefined) {
throw new Error('ENOENT');
}

files.set(to, data);
files.delete(from);
}
};
}

describe('terminal width cache', () => {
let deps: ReturnType<typeof makeDeps>;

beforeEach(() => {
deps = makeDeps(null);
});

it('returns null on a cache miss', () => {
expect(readCachedWidth('session-a', 5, deps)).toBeNull();
});

it('round-trips a width within the TTL', () => {
writeCachedWidth('session-a', 209, deps);
expect(readCachedWidth('session-a', 5, deps)).toEqual({ width: 209 });
});

// The whole point: a cached "no tty" must be a HIT, not a miss, or the
// expensive no-tty case re-probes forever.
it('round-trips a cached null width as a hit', () => {
writeCachedWidth('session-a', null, deps);
expect(readCachedWidth('session-a', 5, deps)).toEqual({ width: null });
});

it('keys entries by session so sessions do not share a width', () => {
writeCachedWidth('session-a', 209, deps);
writeCachedWidth('session-b', 80, deps);
expect(readCachedWidth('session-a', 5, deps)).toEqual({ width: 209 });
expect(readCachedWidth('session-b', 5, deps)).toEqual({ width: 80 });
});

it('treats an entry older than the TTL as a miss', () => {
writeCachedWidth('session-a', 209, deps);
const later = makeDeps(deps.files.get(CACHE_PATH) ?? null, 1_000_000 + 6_000);
expect(readCachedWidth('session-a', 5, later)).toBeNull();
});

it('treats ttlSeconds of 0 as caching disabled (always a miss)', () => {
writeCachedWidth('session-a', 209, deps);
expect(readCachedWidth('session-a', 0, deps)).toBeNull();
});

it('treats a corrupt cache file as a miss and does not throw', () => {
const corrupt = makeDeps('{ this is not json');
expect(() => readCachedWidth('session-a', 5, corrupt)).not.toThrow();
expect(readCachedWidth('session-a', 5, corrupt)).toBeNull();
});

it('never throws when the cache is unwritable', () => {
const unwritable = makeDeps(null);
unwritable.writeFileSync = () => { throw new Error('EACCES'); };
expect(() => { writeCachedWidth('session-a', 209, unwritable); }).not.toThrow();
});

it('prunes entries older than an hour on write', () => {
writeCachedWidth('stale-session', 100, deps);
const muchLater = makeDeps(deps.files.get(CACHE_PATH) ?? null, 1_000_000 + 3_600_001);
writeCachedWidth('fresh-session', 209, muchLater);

const written = JSON.parse(muchLater.files.get(CACHE_PATH) ?? '{}') as { entries: Record<string, unknown> };
expect(Object.keys(written.entries)).toEqual(['fresh-session']);
});

it('writes atomically via a temp file and rename', () => {
const renames: string[] = [];
const tracking = makeDeps(null);
tracking.renameSync = (from: string, to: string) => { renames.push(`${from}->${to}`); };
writeCachedWidth('session-a', 209, tracking);
expect(renames).toHaveLength(1);
expect(renames[0]).toContain(`->${CACHE_PATH}`);
});
});
Loading