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
2 changes: 1 addition & 1 deletion docs/DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ Usage-lock deadlines more than 24 hours ahead are treated as poisoned and ignore
- **Git PR and Git CI Status** render from the versioned disk cache under `~/.cache/ccstatusline/git-review`. Missing or stale entries are refreshed in a detached helper so network-bound `gh` or `glab` calls do not block rendering. Git CI Status adds GitHub's `statusCheckRollup`; if the authenticated `gh` token cannot read checks, the refresh retries with PR metadata only so Git PR still works.
- **Usage widgets** merge Claude Code's stdin `rate_limits` with `/api/oauth/usage` only for fields required by the active widgets. Session and aggregate weekly fields prefer the flat API buckets and fall back to `limits[]`; per-model weekly fields prefer `weekly_scoped` entries. A model-scoped entry reporting 0% without `resets_at` is valid zero usage, while unscoped empty placeholders remain filtered out. `WEEKLY_MODEL_USAGE_BUCKETS` in `src/utils/usage-types.ts` is the shared registry for Sonnet, Opus, and Fable widget wiring, field requirements, reset fields, and scoped-limit matching. Session and weekly percentage widgets delegate rendering and editor behavior to `src/widgets/shared/usage-percent-widget.ts`; the Fable label is `Weekly Fable:`.
- **Custom Command** delegates to `src/utils/custom-command.ts`. `customCommandCacheTtlSeconds` defaults to `0` (disabled), with a maximum of 60 seconds. Both successes and failures are cached, with TTL measured from command completion; without a session ID, entries stay in process memory. Other stdin fields are deliberately excluded from the key. A helper in the current runtime captures stdout in memory, limits it to 1 MiB, and retains at most 16,384 characters; it enforces command deadlines and closes inherited pipes, terminating the process group on POSIX or the shell on Windows when a command times out. Cached raw output is formatted separately by each widget, and previews never execute commands.
- **Terminal width** is memoized once per render, including a `null` probe result. Linux first probes ancestor terminal devices through `/proc` and `tty.WriteStream`; portable fallbacks use `execFileSync` for `ps`, `stty`, and `tput`. `CCSTATUSLINE_WIDTH` takes precedence, including on Windows where probing is disabled. Only no-width results are persisted per session, for `terminalWidthCacheTtlSeconds` (default 5, range 0–300); `0` disables cross-process reuse. Numeric widths are re-probed on the next render.
- **Terminal width** is memoized once per render, including a `null` probe result. Linux first probes ancestor terminal devices through `/proc` and `tty.WriteStream`; portable fallbacks use `execFileSync` for `ps`, `stty`, and `tput`. `CCSTATUSLINE_WIDTH` takes precedence, then the `COLUMNS` variable Claude Code (2.1.153+) sets for the statusline; both apply on Windows where probing is disabled and skip the no-width cache. Only no-width results are persisted per session, for `terminalWidthCacheTtlSeconds` (default 5, range 0–300); `0` disables cross-process reuse. Numeric widths are re-probed on the next render.
- **Context length transcript fallback** treats the latest `compact_boundary` as the start of the current context. It uses the first main-chain usage entry after that boundary, then `compactMetadata.postTokens`, then zero, while session token totals remain cumulative.
- **Sandbox Status** reads `sandbox.enabled` from Claude Code's layered project-local, project, user-local, and user settings on every refresh. This reflects `/sandbox` file updates but remains a best-effort indicator when managed or CLI settings take precedence.

Expand Down
2 changes: 2 additions & 0 deletions docs/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ CCSTATUSLINE_WIDTH=160 ccstatusline

The override is checked before automatic width detection, so it also works in wrapper processes, IDE integrations, nested PTYs, and Windows environments where probing may be unavailable. Invalid values such as `0`, negative numbers, or non-numeric strings are ignored and ccstatusline falls back to normal detection.

Claude Code 2.1.153 and later set `COLUMNS` to the current terminal width when running the status line. ccstatusline reads it after `CCSTATUSLINE_WIDTH` and before any probing, so on current Claude Code versions no width-detection subprocesses run. Invalid values fall back to detection the same way.

On Linux, width detection first uses `/proc` and the terminal device directly, avoiding subprocesses when that probe succeeds. Portable `ps`/`stty`/`tput` fallbacks run without shell wrappers. A probe result is reused throughout one render. If no width is found, that result can also be cached for the same session across renders (default: 5 seconds); a detected width is always re-probed on the next render so resizes take effect immediately. Adjust **Terminal Width Cache TTL** under **Configure Status Line**, or set `terminalWidthCacheTtlSeconds` to `0-300` in `settings.json`; `0` disables the cache across renders.

## Powerline Auto-Alignment
Expand Down
58 changes: 58 additions & 0 deletions src/utils/__tests__/terminal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ describe('terminal utils', () => {
vi.clearAllMocks();
vi.restoreAllMocks();
delete process.env.CCSTATUSLINE_WIDTH;
delete process.env.COLUMNS;
});

beforeEach(() => {
Expand All @@ -60,6 +61,7 @@ describe('terminal utils', () => {
afterEach(() => {
vi.restoreAllMocks();
delete process.env.CCSTATUSLINE_WIDTH;
delete process.env.COLUMNS;
setPlatform(ORIGINAL_PLATFORM);
});

Expand Down Expand Up @@ -282,6 +284,52 @@ describe('terminal utils', () => {
expect(mockExecFileSync.mock.calls.length).toBe(0);
});

it('uses COLUMNS from Claude Code without probing', () => {
pinPosixPlatform();
process.env.COLUMNS = '132';

expect(getTerminalWidth()).toBe(132);
expect(mockExecFileSync.mock.calls.length).toBe(0);
});

it('prefers CCSTATUSLINE_WIDTH over COLUMNS', () => {
process.env.CCSTATUSLINE_WIDTH = '200';
process.env.COLUMNS = '132';

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

it.each(['0', 'wide'])('ignores COLUMNS=%s and falls back to probing', (value) => {
pinPosixPlatform();
process.env.COLUMNS = value;

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 === 'stty' && args.join(' ') === '-F /dev/ttys001 size') {
return '24 160\n';
}

throw new Error(`Unexpected command: ${file} ${args.join(' ')}`);
});

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

it('COLUMNS applies on Windows where probing is disabled', () => {
setPlatform('win32');
process.env.COLUMNS = '140';

expect(getTerminalWidth()).toBe(140);
expect(canDetectTerminalWidth()).toBe(true);
});

it('disables width detection on Windows', () => {
setPlatform('win32');

Expand Down Expand Up @@ -420,6 +468,16 @@ describe('terminal utils', () => {
expect(mockExecFileSync).not.toHaveBeenCalled();
});

it('honors COLUMNS even when the session has a cached no-TTY result', () => {
readSpy.mockReturnValue({ width: null });
process.env.COLUMNS = '132';

expect(getTerminalWidth({ sessionId: 'session-a', ttlSeconds: 300 })).toBe(132);
expect(readSpy).not.toHaveBeenCalled();
expect(writeSpy).not.toHaveBeenCalled();
expect(mockExecFileSync).not.toHaveBeenCalled();
});

it('persists a "no TTY" probe result to the L2 cache with the given sessionId', () => {
pinPosixPlatform();
mockExecFileSync.mockImplementation(() => {
Expand Down
14 changes: 14 additions & 0 deletions src/utils/terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,20 @@ export function getTerminalWidth(options?: TerminalWidthOptions): number | null
}
}

// Claude Code >= 2.1.153 sets COLUMNS to the terminal width when it spawns
// the statusline, since its captured stdio leaves no TTY to probe. Reading
// it skips the ancestor walk (several ps/stty calls per render on macOS).
// Checked before the L2 cache so a stale "no TTY" entry cannot hide it.
const columnsRaw = process.env.COLUMNS;
if (columnsRaw) {
const columns = parsePositiveInteger(columnsRaw);
if (columns !== null) {
cachedWidth = columns;
hasProbed = true;
return cachedWidth;
}
}

const sessionId = options?.sessionId;
const ttlSeconds = options?.ttlSeconds ?? 0;

Expand Down