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

## 🆕 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 "no TTY" case 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**. That `null` result is now memoized in-process for the render, and persisted per-session via `~/.cache/ccstatusline/terminal-width.json` so later renders in the same session skip the walk entirely. A *discovered* width is deliberately never persisted across processes (only kept in-process for that one render), so a terminal resize — or resuming the same session in a differently-sized terminal — is always reflected on the next render rather than delayed by a stale cross-process cache entry.
- **⏱️ `terminalWidthCacheTtlSeconds`** - New setting, default `5`, range 0–300. Controls how long a cached "no TTY" result is trusted before re-probing, in case a session moves from a non-interactive context into one with a real terminal. Set to `0` to always re-probe. (Note: unlike `gitCacheTtlSeconds`, where `0` means "never expire", `0` here disables the cache.)
### v2.2.27 - Portable configuration import and export

- **📦 Config import/export** - Export the current TUI configuration to JSON, validate and preview imports, then replace all settings or merge only supplied fields while preserving local installation metadata and leaving the result unsaved for review.
Expand Down
5 changes: 4 additions & 1 deletion src/ccstatusline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,10 @@ 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,
Expand Down
12 changes: 12 additions & 0 deletions src/tui/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1360,6 +1360,7 @@ export const App: React.FC = () => {
currentInterval={currentRefreshInterval}
supportsRefreshInterval={supportsRefreshInterval}
gitCacheTtlSeconds={settings.gitCacheTtlSeconds}
terminalWidthCacheTtlSeconds={settings.terminalWidthCacheTtlSeconds}
onUpdate={(interval) => {
const previous = currentRefreshInterval;
setCurrentRefreshInterval(interval);
Expand Down Expand Up @@ -1390,6 +1391,17 @@ export const App: React.FC = () => {
});
setScreen('main');
}}
onTerminalWidthCacheTtlUpdate={(ttlSeconds) => {
setSettings({
...settings,
terminalWidthCacheTtlSeconds: ttlSeconds
});
setFlashMessage({
text: '✓ Terminal Width cache TTL updated',
color: 'green'
});
setScreen('main');
}}
onBack={() => {
setScreen('main');
}}
Expand Down
105 changes: 100 additions & 5 deletions src/tui/components/RefreshIntervalMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
type ListEntry
} from './List';

type ConfigureStatusLineValue = 'refreshInterval' | 'gitCacheTtl';
type ConfigureStatusLineValue = 'refreshInterval' | 'gitCacheTtl' | 'terminalWidthCacheTtl';

function getRefreshInputValue(interval: number | null): string {
return interval === null ? '' : String(interval);
Expand All @@ -36,10 +36,17 @@ function getGitCacheTtlSublabel(ttlSeconds: number): string {
: `(${ttlSeconds}s)`;
}

function getTerminalWidthCacheTtlSublabel(ttlSeconds: number): string {
return ttlSeconds === 0
? '(disabled)'
: `(${ttlSeconds}s)`;
}

export function buildConfigureStatusLineItems(
refreshInterval: number | null,
supportsRefreshInterval: boolean,
gitCacheTtlSeconds: number
gitCacheTtlSeconds: number,
terminalWidthCacheTtlSeconds: number
): ListEntry<ConfigureStatusLineValue>[] {
return [
{
Expand All @@ -56,6 +63,12 @@ export function buildConfigureStatusLineItems(
sublabel: getGitCacheTtlSublabel(gitCacheTtlSeconds),
value: 'gitCacheTtl',
description: 'How long git widget subprocess output can be reused while .git/HEAD and .git/index are unchanged. Enter 0-60 seconds;\n0 disables age-based expiry, so cached output is reused until those git metadata mtimes change.'
},
{
label: '🖥️ Terminal Width Cache TTL',
sublabel: getTerminalWidthCacheTtlSublabel(terminalWidthCacheTtlSeconds),
value: 'terminalWidthCacheTtl',
description: 'How long a cached "no TTY detected" result is trusted before re-probing the terminal width. Enter 0-300 seconds;\n0 disables the cache (always re-probes). A detected width is never cached across renders, only this no-TTY result is.'
}
];
}
Expand Down Expand Up @@ -100,27 +113,51 @@ export function validateGitCacheTtlInput(value: string): string | null {
return null;
}

export function validateTerminalWidthCacheTtlInput(value: string): string | null {
const parsed = parseInt(value, 10);

if (value === '' || isNaN(parsed)) {
return 'Please enter a valid number';
}

if (parsed < 0) {
return `Minimum Terminal Width cache TTL is 0s (you entered ${parsed}s)`;
}

if (parsed > 300) {
return `Maximum Terminal Width cache TTL is 300s (you entered ${parsed}s)`;
}

return null;
}

export interface RefreshIntervalMenuProps {
currentInterval: number | null;
supportsRefreshInterval: boolean;
gitCacheTtlSeconds: number;
terminalWidthCacheTtlSeconds: number;
onUpdate: (interval: number | null) => void;
onGitCacheTtlUpdate: (ttlSeconds: number) => void;
onTerminalWidthCacheTtlUpdate: (ttlSeconds: number) => void;
onBack: () => void;
}

export const RefreshIntervalMenu: React.FC<RefreshIntervalMenuProps> = ({
currentInterval,
supportsRefreshInterval,
gitCacheTtlSeconds,
terminalWidthCacheTtlSeconds,
onUpdate,
onGitCacheTtlUpdate,
onTerminalWidthCacheTtlUpdate,
onBack
}) => {
const [editingRefreshInterval, setEditingRefreshInterval] = useState(false);
const [editingGitCacheTtl, setEditingGitCacheTtl] = useState(false);
const [editingTerminalWidthCacheTtl, setEditingTerminalWidthCacheTtl] = useState(false);
const [refreshInput, setRefreshInput] = useState(() => getRefreshInputValue(currentInterval));
const [gitCacheTtlInput, setGitCacheTtlInput] = useState(() => String(gitCacheTtlSeconds));
const [terminalWidthCacheTtlInput, setTerminalWidthCacheTtlInput] = useState(() => String(terminalWidthCacheTtlSeconds));
const [validationError, setValidationError] = useState<string | null>(null);

useInput((input, key) => {
Expand Down Expand Up @@ -193,6 +230,37 @@ export const RefreshIntervalMenu: React.FC<RefreshIntervalMenuProps> = ({
return;
}

if (editingTerminalWidthCacheTtl) {
if (key.return) {
const error = validateTerminalWidthCacheTtlInput(terminalWidthCacheTtlInput);

if (error) {
setValidationError(error);
} else {
const value = parseInt(terminalWidthCacheTtlInput, 10);
onTerminalWidthCacheTtlUpdate(value);
setEditingTerminalWidthCacheTtl(false);
setValidationError(null);
}
} else if (key.escape) {
setTerminalWidthCacheTtlInput(String(terminalWidthCacheTtlSeconds));
setEditingTerminalWidthCacheTtl(false);
setValidationError(null);
} else if (key.backspace) {
setTerminalWidthCacheTtlInput(terminalWidthCacheTtlInput.slice(0, -1));
setValidationError(null);
} else if (key.delete) {
// No cursor position in simple input
} else if (shouldInsertInput(input, key) && /\d/.test(input)) {
const newValue = terminalWidthCacheTtlInput + input;
if (newValue.length <= 3) {
setTerminalWidthCacheTtlInput(newValue);
setValidationError(null);
}
}
return;
}

if (key.escape) {
onBack();
}
Expand Down Expand Up @@ -238,10 +306,31 @@ export const RefreshIntervalMenu: React.FC<RefreshIntervalMenuProps> = ({
)}
<Text dimColor>Press Enter to confirm, ESC to cancel.</Text>
</Box>
) : editingTerminalWidthCacheTtl ? (
<Box marginTop={1} flexDirection='column'>
<Text>
Enter Terminal Width cache TTL in seconds (0-300):
{' '}
{terminalWidthCacheTtlInput}
{terminalWidthCacheTtlInput.length > 0 ? 's' : ''}
</Text>
<Text> </Text>
<Text dimColor wrap='wrap'>
This affects how long a "no TTY detected" result is trusted before re-probing.
</Text>
{validationError ? (
<Text color='red'>{validationError}</Text>
) : (
<Text dimColor>
0 disables the cache, always re-probing. A detected width is never cached across renders.
</Text>
)}
<Text dimColor>Press Enter to confirm, ESC to cancel.</Text>
</Box>
) : (
<List
marginTop={1}
items={buildConfigureStatusLineItems(currentInterval, supportsRefreshInterval, gitCacheTtlSeconds)}
items={buildConfigureStatusLineItems(currentInterval, supportsRefreshInterval, gitCacheTtlSeconds, terminalWidthCacheTtlSeconds)}
onSelect={(value) => {
if (value === 'back') {
onBack();
Expand All @@ -254,8 +343,14 @@ export const RefreshIntervalMenu: React.FC<RefreshIntervalMenuProps> = ({
return;
}

setGitCacheTtlInput(String(gitCacheTtlSeconds));
setEditingGitCacheTtl(true);
if (value === 'gitCacheTtl') {
setGitCacheTtlInput(String(gitCacheTtlSeconds));
setEditingGitCacheTtl(true);
return;
}

setTerminalWidthCacheTtlInput(String(terminalWidthCacheTtlSeconds));
setEditingTerminalWidthCacheTtl(true);
}}
showBackButton={true}
/>
Expand Down
Loading