From 8c0c94540c2f797264e7babed5fbc2559919784c Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Mon, 29 Jun 2026 17:49:39 -0400 Subject: [PATCH] COD-80 centralize terminal history/scrollback/buffer limits into config Introduce src/config/terminal-history.ts: one place for terminal scrollback, tmux history-limit, and PTY buffer byte caps, each overridable via env var or the settings object and bounds-clamped via resolveTerminalHistoryConfig(). Defaults match the prior hardcoded values, so this is behavior-neutral. Wires the resolver through buffer-limits, tmux-manager (incl. a setHistoryLimit so a settings change applies live), session, server, system-routes, session-routes, schemas, and the config port. Adds 4 optional settings keys (terminalScrollback Lines, tmuxHistoryLimit, terminalBufferMaxBytes, terminalBufferTrimBytes) with bounds + a trim<=max cross-check. --- src/config/buffer-limits.ts | 10 ++- src/config/terminal-history.ts | 65 ++++++++++++++++ src/mux-interface.ts | 7 ++ src/session.ts | 11 +++ src/tmux-manager.ts | 37 ++++++++- src/web/ports/config-port.ts | 2 + src/web/routes/session-routes.ts | 4 + src/web/routes/system-routes.ts | 6 ++ src/web/schemas.ts | 31 +++++++- src/web/server.ts | 8 ++ test/mocks/mock-route-context.ts | 3 + test/terminal-history-schema.test.ts | 67 ++++++++++++++++ test/terminal-history.test.ts | 110 +++++++++++++++++++++++++++ 13 files changed, 355 insertions(+), 6 deletions(-) create mode 100644 src/config/terminal-history.ts create mode 100644 test/terminal-history-schema.test.ts create mode 100644 test/terminal-history.test.ts diff --git a/src/config/buffer-limits.ts b/src/config/buffer-limits.ts index dd6fe5a7..4f28c69c 100644 --- a/src/config/buffer-limits.ts +++ b/src/config/buffer-limits.ts @@ -9,11 +9,13 @@ * - Terminal buffer: 2MB max × 20 = 40MB worst case * - Text output: 1MB max × 20 = 20MB worst case * - Messages: ~1KB each × 1000 × 20 = 20MB worst case - * - Total buffer overhead: ~80MB (acceptable for long-running server) + * - Total buffer overhead: ~80MB (acceptable for a long-running server) * * @module config/buffer-limits */ +import { DEFAULT_TERMINAL_BUFFER_MAX_BYTES, DEFAULT_TERMINAL_BUFFER_TRIM_BYTES } from './terminal-history.js'; + // ============================================================================ // Terminal Buffer Limits // ============================================================================ @@ -21,17 +23,17 @@ /** * Maximum terminal buffer size in characters. * Contains raw terminal output with ANSI escape sequences. - * Reduced from 5MB to 2MB for better render performance. + * Sourced from terminal-history config (env/settings overridable). * Override: CODEMAN_MAX_TERMINAL_BUFFER (bytes) */ -export const MAX_TERMINAL_BUFFER_SIZE = parseInt(process.env.CODEMAN_MAX_TERMINAL_BUFFER || '') || 2 * 1024 * 1024; +export const MAX_TERMINAL_BUFFER_SIZE = DEFAULT_TERMINAL_BUFFER_MAX_BYTES; /** * Size to trim terminal buffer to when max is exceeded. * Keeps the most recent portion to preserve context. * Override: CODEMAN_TRIM_TERMINAL_TO (bytes) */ -export const TRIM_TERMINAL_TO = parseInt(process.env.CODEMAN_TRIM_TERMINAL_TO || '') || 1.5 * 1024 * 1024; +export const TRIM_TERMINAL_TO = DEFAULT_TERMINAL_BUFFER_TRIM_BYTES; // ============================================================================ // Text Output Buffer Limits diff --git a/src/config/terminal-history.ts b/src/config/terminal-history.ts new file mode 100644 index 00000000..f4f89605 --- /dev/null +++ b/src/config/terminal-history.ts @@ -0,0 +1,65 @@ +/** + * Defaults, bounds, and resolution for terminal history retention. + * + * Centralizes the terminal scrollback, tmux history-limit, and server PTY buffer + * byte caps that were previously scattered as hardcoded literals. Each value is + * overridable (env var or the settings object) and clamped to a sane range via + * resolveTerminalHistoryConfig(). Defaults intentionally match the prior + * hardcoded values, so introducing this module is behavior-neutral. + */ + +export const DEFAULT_TERMINAL_SCROLLBACK_LINES = 50_000; +export const DEFAULT_TMUX_HISTORY_LIMIT = 50_000; +export const DEFAULT_TERMINAL_BUFFER_MAX_BYTES = + parseInt(process.env.CODEMAN_MAX_TERMINAL_BUFFER || '', 10) || 2 * 1024 * 1024; +export const DEFAULT_TERMINAL_BUFFER_TRIM_BYTES = + parseInt(process.env.CODEMAN_TRIM_TERMINAL_TO || '', 10) || 1.5 * 1024 * 1024; + +export const MIN_TERMINAL_SCROLLBACK_LINES = 1_000; +export const MAX_TERMINAL_SCROLLBACK_LINES = 1_000_000; +export const MIN_TERMINAL_BUFFER_BYTES = 1024 * 1024; +export const MAX_TERMINAL_BUFFER_BYTES = 128 * 1024 * 1024; + +export interface TerminalHistoryConfig { + terminalScrollbackLines: number; + tmuxHistoryLimit: number; + terminalBufferMaxBytes: number; + terminalBufferTrimBytes: number; +} + +function boundedInt(value: unknown, fallback: number, min: number, max: number): number { + if (typeof value !== 'number' || !Number.isFinite(value)) return fallback; + return Math.max(min, Math.min(max, Math.trunc(value))); +} + +export function resolveTerminalHistoryConfig(settings: Record = {}): TerminalHistoryConfig { + const terminalBufferMaxBytes = boundedInt( + settings.terminalBufferMaxBytes, + DEFAULT_TERMINAL_BUFFER_MAX_BYTES, + MIN_TERMINAL_BUFFER_BYTES, + MAX_TERMINAL_BUFFER_BYTES + ); + const terminalBufferTrimBytes = boundedInt( + settings.terminalBufferTrimBytes, + Math.min(DEFAULT_TERMINAL_BUFFER_TRIM_BYTES, terminalBufferMaxBytes), + MIN_TERMINAL_BUFFER_BYTES, + terminalBufferMaxBytes + ); + + return { + terminalScrollbackLines: boundedInt( + settings.terminalScrollbackLines, + DEFAULT_TERMINAL_SCROLLBACK_LINES, + MIN_TERMINAL_SCROLLBACK_LINES, + MAX_TERMINAL_SCROLLBACK_LINES + ), + tmuxHistoryLimit: boundedInt( + settings.tmuxHistoryLimit, + DEFAULT_TMUX_HISTORY_LIMIT, + MIN_TERMINAL_SCROLLBACK_LINES, + MAX_TERMINAL_SCROLLBACK_LINES + ), + terminalBufferMaxBytes, + terminalBufferTrimBytes, + }; +} diff --git a/src/mux-interface.ts b/src/mux-interface.ts index 9bc7dfec..8c053c84 100644 --- a/src/mux-interface.ts +++ b/src/mux-interface.ts @@ -72,6 +72,8 @@ export interface CreateSessionOptions { envOverrides?: Record; /** Claude CLI effort level, injected as a `--settings` soft default (overridable via /effort in-session) */ effort?: EffortLevel; + /** tmux history-limit (scrollback lines) to set for this session. */ + historyLimit?: number; } /** Options for respawning a dead pane. */ @@ -92,6 +94,8 @@ export interface RespawnPaneOptions { envOverrides?: Record; /** Claude CLI effort level (preserved across respawns, injected via `--settings`) */ effort?: EffortLevel; + /** tmux history-limit (scrollback lines) to set for this session after respawn. */ + historyLimit?: number; } /** @@ -170,6 +174,9 @@ export interface TerminalMultiplexer extends EventEmitter { /** Update Ralph enabled state for a session */ updateRalphEnabled(sessionId: string, enabled: boolean): void; + /** Apply a tmux history-limit to all tracked sessions. */ + setHistoryLimit(limit: number): Promise; + // ========== Discovery ========== /** diff --git a/src/session.ts b/src/session.ts index 107c413c..daeb1667 100644 --- a/src/session.ts +++ b/src/session.ts @@ -70,6 +70,7 @@ import { MAX_MESSAGES, MAX_LINE_BUFFER_SIZE, } from './config/buffer-limits.js'; +import { DEFAULT_TMUX_HISTORY_LIMIT } from './config/terminal-history.js'; import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js'; import { buildInteractiveArgs, @@ -381,6 +382,9 @@ export class Session extends EventEmitter { // the CLAUDE_CODE_EFFORT_LEVEL env var, which would hard-lock the session. private _effort: EffortLevel | undefined; + // tmux history-limit (scrollback lines) applied to this session's pane. + private readonly _tmuxHistoryLimit: number; + // Session color for visual differentiation private _color: import('./types.js').SessionColor = 'default'; @@ -448,6 +452,8 @@ export class Session extends EventEmitter { envOverrides?: Record; /** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */ effort?: EffortLevel; + /** tmux history-limit (scrollback lines) for this session's pane. */ + tmuxHistoryLimit?: number; /** Restored per-session attachment history. May include server-private external paths. */ attachmentHistory?: SessionAttachmentHistoryItem[]; } @@ -521,6 +527,7 @@ export class Session extends EventEmitter { if (config.effort && isEffortLevel(config.effort)) { this._effort = config.effort; } + this._tmuxHistoryLimit = config.tmuxHistoryLimit ?? DEFAULT_TMUX_HISTORY_LIMIT; if (config.attachmentHistory && config.attachmentHistory.length > 0) { this.restoreAttachmentHistory(config.attachmentHistory); } @@ -1274,6 +1281,7 @@ export class Session extends EventEmitter { resumeSessionId: this._resumeSessionId, envOverrides: this._envOverrides, effort: this._effort, + historyLimit: this._tmuxHistoryLimit, }, createSessionOptions: { sessionId: this.id, @@ -1290,6 +1298,7 @@ export class Session extends EventEmitter { resumeSessionId: this._resumeSessionId, envOverrides: this._envOverrides, effort: this._effort, + historyLimit: this._tmuxHistoryLimit, }, spawnErrLabel: 'mux attachment', }); @@ -1627,6 +1636,7 @@ export class Session extends EventEmitter { mode: 'shell', niceConfig: this._niceConfig, envOverrides: this._envOverrides, + historyLimit: this._tmuxHistoryLimit, }, createSessionOptions: { sessionId: this.id, @@ -1635,6 +1645,7 @@ export class Session extends EventEmitter { name: this._name, niceConfig: this._niceConfig, envOverrides: this._envOverrides, + historyLimit: this._tmuxHistoryLimit, }, spawnErrLabel: 'shell mux attachment', }); diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index 956001ed..0af5caa8 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -65,6 +65,7 @@ import type { // ============================================================================ import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js'; +import { DEFAULT_TMUX_HISTORY_LIMIT } from './config/terminal-history.js'; /** Delay after tmux session creation — enough for detached tmux to be queryable */ const TMUX_CREATION_WAIT_MS = 100; @@ -1057,6 +1058,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { resumeSessionId, envOverrides, effort, + historyLimit = DEFAULT_TMUX_HISTORY_LIMIT, } = options; const muxName = `codeman-${sessionId.slice(0, 8)}`; @@ -1201,7 +1203,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { }), // Raise tmux scrollback from its 2000-line default so re-attach preserves // more context. Matches the xterm-side default in constants.js. - execAsync(`${this.tmux()} set-option -t "${muxName}" history-limit 50000`, { timeout: EXEC_TIMEOUT_MS }) + execAsync(`${this.tmux()} set-option -t "${muxName}" history-limit ${historyLimit}`, { + timeout: EXEC_TIMEOUT_MS, + }) .then(() => {}) .catch(() => { /* Non-critical — falls back to tmux default */ @@ -1324,6 +1328,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { resumeSessionId, envOverrides, effort, + historyLimit = DEFAULT_TMUX_HISTORY_LIMIT, } = options; const session = this.sessions.get(sessionId); if (!session) return null; @@ -1331,6 +1336,16 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { if (!isValidMuxName(muxName) || !isValidPath(workingDir)) return null; + // Re-apply the configured tmux history-limit after respawn (kept in sync + // with the live setting via setHistoryLimit()). + if (!IS_TEST_MODE) { + await execAsync(`${this.tmux()} set-option -t ${shellescape(muxName)} history-limit ${historyLimit}`, { + timeout: EXEC_TIMEOUT_MS, + }).catch(() => { + /* Non-critical — keeps existing tmux history-limit */ + }); + } + // Resolve CLI binary directory based on mode const { pathExport } = this.buildPathExport(mode); @@ -1935,6 +1950,26 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { } } + /** + * Apply a tmux history-limit to all tracked sessions (e.g. when the user + * changes the terminal-history setting). Invalid limits fall back to the + * default. Best-effort per session. + */ + async setHistoryLimit(limit: number): Promise { + const safeLimit = Number.isSafeInteger(limit) && limit > 0 ? Math.trunc(limit) : DEFAULT_TMUX_HISTORY_LIMIT; + + if (IS_TEST_MODE) { + return; + } + + const updates = Array.from(this.sessions.values()).map((session) => + execAsync(`${this.tmux()} set-option -t ${shellescape(session.muxName)} history-limit ${safeLimit}`, { + timeout: EXEC_TIMEOUT_MS, + }) + ); + await Promise.allSettled(updates); + } + /** * Send input directly to a tmux session using `send-keys`. * diff --git a/src/web/ports/config-port.ts b/src/web/ports/config-port.ts index 9e8f347d..9d249cc0 100644 --- a/src/web/ports/config-port.ts +++ b/src/web/ports/config-port.ts @@ -5,6 +5,7 @@ import type { ClaudeMode, NiceConfig } from '../../types.js'; import type { StateStore } from '../../state-store.js'; +import type { TerminalHistoryConfig } from '../../config/terminal-history.js'; export interface ConfigPort { readonly store: StateStore; @@ -15,6 +16,7 @@ export interface ConfigPort { getGlobalNiceConfig(): Promise; getModelConfig(): Promise<{ defaultModel?: string; agentTypeOverrides?: Record } | null>; getClaudeModeConfig(): Promise<{ claudeMode?: ClaudeMode; allowedTools?: string }>; + getTerminalHistoryConfig(): Promise; getDefaultClaudeMdPath(): Promise; getLightState(): unknown; getLightSessionsState(): unknown[]; diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index e7efef45..659731ad 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -406,6 +406,7 @@ export function registerSessionRoutes( ? modelConfig?.defaultModel || undefined : undefined; const claudeModeConfig = await ctx.getClaudeModeConfig(); + const terminalHistoryConfig = await ctx.getTerminalHistoryConfig(); const session = new Session({ workingDir, mode, @@ -422,6 +423,7 @@ export function registerSessionRoutes( resumeSessionId: validatedResumeId, envOverrides: body.envOverrides, effort: body.effort, + tmuxHistoryLimit: terminalHistoryConfig.tmuxHistoryLimit, }); ctx.addSession(session); @@ -1362,6 +1364,7 @@ export function registerSessionRoutes( ? qsModelConfig?.defaultModel || undefined : undefined; const qsClaudeModeConfig = await ctx.getClaudeModeConfig(); + const qsTerminalHistoryConfig = await ctx.getTerminalHistoryConfig(); const session = new Session({ workingDir: casePath, mux: ctx.mux, @@ -1376,6 +1379,7 @@ export function registerSessionRoutes( geminiConfig: mode === 'gemini' ? geminiConfig : undefined, envOverrides, effort, + tmuxHistoryLimit: qsTerminalHistoryConfig.tmuxHistoryLimit, }); // Auto-detect completion phrase from CLAUDE.md BEFORE broadcasting diff --git a/src/web/routes/system-routes.ts b/src/web/routes/system-routes.ts index c45e9534..17484463 100644 --- a/src/web/routes/system-routes.ts +++ b/src/web/routes/system-routes.ts @@ -49,6 +49,7 @@ import type { SessionPort, EventPort, ConfigPort, InfraPort, AuthPort } from '.. import { AUTH_COOKIE_NAME } from '../middleware/auth.js'; import { QR_AUTH_FAILURE_MAX } from '../../config/tunnel-config.js'; import { AUTH_SESSION_TTL_MS } from '../../config/auth-config.js'; +import { resolveTerminalHistoryConfig } from '../../config/terminal-history.js'; // Maximum screenshot upload size (10MB) const MAX_SCREENSHOT_SIZE = 10 * 1024 * 1024; @@ -624,6 +625,11 @@ export function registerSystemRoutes( const merged = { ...existing, ...settingsToStore }; await fs.writeFile(SETTINGS_PATH, JSON.stringify(merged, null, 2)); + // Apply a changed tmux history-limit to all live sessions immediately. + if (settings.tmuxHistoryLimit !== undefined) { + await ctx.mux.setHistoryLimit(resolveTerminalHistoryConfig(merged).tmuxHistoryLimit); + } + // Handle subagent tracking toggle dynamically toggleService((settings.subagentTrackingEnabled as boolean) ?? true, subagentWatcher, 'Subagent watcher'); diff --git a/src/web/schemas.ts b/src/web/schemas.ts index a6dc1416..45aeddd4 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -9,6 +9,12 @@ import { z } from 'zod'; import { SAFE_PATH_PATTERN, isSafePushEndpoint } from '../utils/index.js'; +import { + MAX_TERMINAL_BUFFER_BYTES, + MAX_TERMINAL_SCROLLBACK_LINES, + MIN_TERMINAL_BUFFER_BYTES, + MIN_TERMINAL_SCROLLBACK_LINES, +} from '../config/terminal-history.js'; // ========== Path Validation ========== @@ -412,6 +418,16 @@ export const SettingsUpdateSchema = z allowedTools: z.string().max(2000).optional(), // Codex CLI settings codexDangerouslyBypassApprovals: z.boolean().optional(), + // Terminal history and retention + terminalScrollbackLines: z + .number() + .int() + .min(MIN_TERMINAL_SCROLLBACK_LINES) + .max(MAX_TERMINAL_SCROLLBACK_LINES) + .optional(), + tmuxHistoryLimit: z.number().int().min(MIN_TERMINAL_SCROLLBACK_LINES).max(MAX_TERMINAL_SCROLLBACK_LINES).optional(), + terminalBufferMaxBytes: z.number().int().min(MIN_TERMINAL_BUFFER_BYTES).max(MAX_TERMINAL_BUFFER_BYTES).optional(), + terminalBufferTrimBytes: z.number().int().min(MIN_TERMINAL_BUFFER_BYTES).max(MAX_TERMINAL_BUFFER_BYTES).optional(), // CPU priority nice: z .object({ @@ -480,7 +496,20 @@ export const SettingsUpdateSchema = z .max(20) .optional(), }) - .strict(); + .strict() + .superRefine((settings, ctx) => { + if ( + settings.terminalBufferMaxBytes !== undefined && + settings.terminalBufferTrimBytes !== undefined && + settings.terminalBufferTrimBytes > settings.terminalBufferMaxBytes + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['terminalBufferTrimBytes'], + message: 'terminalBufferTrimBytes must be less than or equal to terminalBufferMaxBytes', + }); + } + }); /** * Schema for POST /api/sessions/:id/input with length limit diff --git a/src/web/server.ts b/src/web/server.ts index 61324c2b..428630cc 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -129,6 +129,7 @@ import { import type { EventLoopMonitorHandle } from '../utils/index.js'; import { MAX_CONCURRENT_SESSIONS, MAX_SSE_CLIENTS } from '../config/map-limits.js'; import { MAX_PASTE_IMAGE_BYTES } from '../config/buffer-limits.js'; +import { resolveTerminalHistoryConfig } from '../config/terminal-history.js'; import { SseEvent } from './sse-events.js'; import { getLatestPlanUsage } from './plan-usage-latest.js'; import type { ScheduledRun } from './ports/index.js'; @@ -587,6 +588,7 @@ export class WebServer extends EventEmitter { getGlobalNiceConfig: this.getGlobalNiceConfig.bind(this), getModelConfig: this.getModelConfig.bind(this), getClaudeModeConfig: this.getClaudeModeConfig.bind(this), + getTerminalHistoryConfig: this.getTerminalHistoryConfig.bind(this), getDefaultClaudeMdPath: this.getDefaultClaudeMdPath.bind(this), getLightState: this.getLightState.bind(this), getLightSessionsState: this.getLightSessionsState.bind(this), @@ -1463,6 +1465,12 @@ export class WebServer extends EventEmitter { return {}; } + // Resolve the bounds-clamped terminal-history config from settings.json. + private async getTerminalHistoryConfig() { + const settings = await this.readSettings(); + return resolveTerminalHistoryConfig(settings); + } + // Helper to get model configuration from settings private async getModelConfig(): Promise<{ defaultModel?: string; diff --git a/test/mocks/mock-route-context.ts b/test/mocks/mock-route-context.ts index dfcbe2a5..e7dedc62 100644 --- a/test/mocks/mock-route-context.ts +++ b/test/mocks/mock-route-context.ts @@ -9,6 +9,7 @@ */ import { vi } from 'vitest'; import { MockSession, createMockSession } from './mock-session.js'; +import { resolveTerminalHistoryConfig } from '../../src/config/terminal-history.js'; /** * Creates a mock context that satisfies all port interfaces. @@ -76,6 +77,7 @@ export function createMockRouteContext(options?: { sessionId?: string }) { getGlobalNiceConfig: vi.fn(async () => undefined), getModelConfig: vi.fn(async () => null), getClaudeModeConfig: vi.fn(async () => ({})), + getTerminalHistoryConfig: vi.fn(async () => resolveTerminalHistoryConfig({})), getDefaultClaudeMdPath: vi.fn(async () => undefined), getLightState: vi.fn(() => ({ sessions: [], status: 'ok' })), getLightSessionsState: vi.fn(() => { @@ -99,6 +101,7 @@ export function createMockRouteContext(options?: { sessionId?: string }) { getSession: vi.fn(() => null), clearRespawnConfig: vi.fn(), updateRespawnConfig: vi.fn(), + setHistoryLimit: vi.fn(async () => {}), }, runSummaryTrackers: new Map(), activePlanOrchestrators: new Map(), diff --git a/test/terminal-history-schema.test.ts b/test/terminal-history-schema.test.ts new file mode 100644 index 00000000..659758b8 --- /dev/null +++ b/test/terminal-history-schema.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect } from 'vitest'; +import { SettingsUpdateSchema } from '../src/web/schemas.js'; +import { + MAX_TERMINAL_SCROLLBACK_LINES, + MIN_TERMINAL_SCROLLBACK_LINES, + MAX_TERMINAL_BUFFER_BYTES, + MIN_TERMINAL_BUFFER_BYTES, +} from '../src/config/terminal-history.js'; + +describe('SettingsUpdateSchema — terminal history keys', () => { + it('accepts valid in-range terminal-history settings', () => { + const res = SettingsUpdateSchema.safeParse({ + terminalScrollbackLines: 100_000, + tmuxHistoryLimit: 100_000, + terminalBufferMaxBytes: 32 * 1024 * 1024, + terminalBufferTrimBytes: 24 * 1024 * 1024, + }); + expect(res.success).toBe(true); + }); + + it('accepts a settings object that omits the terminal-history keys', () => { + const res = SettingsUpdateSchema.safeParse({ allowedTools: 'Bash' }); + expect(res.success).toBe(true); + }); + + it('rejects scrollback below the minimum', () => { + const res = SettingsUpdateSchema.safeParse({ terminalScrollbackLines: MIN_TERMINAL_SCROLLBACK_LINES - 1 }); + expect(res.success).toBe(false); + }); + + it('rejects scrollback above the maximum', () => { + const res = SettingsUpdateSchema.safeParse({ tmuxHistoryLimit: MAX_TERMINAL_SCROLLBACK_LINES + 1 }); + expect(res.success).toBe(false); + }); + + it('rejects buffer bytes outside the byte bounds', () => { + expect(SettingsUpdateSchema.safeParse({ terminalBufferMaxBytes: MIN_TERMINAL_BUFFER_BYTES - 1 }).success).toBe( + false + ); + expect(SettingsUpdateSchema.safeParse({ terminalBufferMaxBytes: MAX_TERMINAL_BUFFER_BYTES + 1 }).success).toBe( + false + ); + }); + + it('rejects non-integer values', () => { + expect(SettingsUpdateSchema.safeParse({ terminalScrollbackLines: 12_345.5 }).success).toBe(false); + }); + + it('rejects trim > max via the cross-field superRefine', () => { + const res = SettingsUpdateSchema.safeParse({ + terminalBufferMaxBytes: 4 * 1024 * 1024, + terminalBufferTrimBytes: 8 * 1024 * 1024, + }); + expect(res.success).toBe(false); + if (!res.success) { + expect(res.error.issues.some((i) => i.path.includes('terminalBufferTrimBytes'))).toBe(true); + } + }); + + it('allows trim == max', () => { + const res = SettingsUpdateSchema.safeParse({ + terminalBufferMaxBytes: 8 * 1024 * 1024, + terminalBufferTrimBytes: 8 * 1024 * 1024, + }); + expect(res.success).toBe(true); + }); +}); diff --git a/test/terminal-history.test.ts b/test/terminal-history.test.ts new file mode 100644 index 00000000..fac45905 --- /dev/null +++ b/test/terminal-history.test.ts @@ -0,0 +1,110 @@ +import { describe, it, expect } from 'vitest'; +import { + resolveTerminalHistoryConfig, + DEFAULT_TERMINAL_SCROLLBACK_LINES, + DEFAULT_TMUX_HISTORY_LIMIT, + DEFAULT_TERMINAL_BUFFER_MAX_BYTES, + DEFAULT_TERMINAL_BUFFER_TRIM_BYTES, + MIN_TERMINAL_SCROLLBACK_LINES, + MAX_TERMINAL_SCROLLBACK_LINES, + MIN_TERMINAL_BUFFER_BYTES, + MAX_TERMINAL_BUFFER_BYTES, +} from '../src/config/terminal-history.js'; + +describe('resolveTerminalHistoryConfig', () => { + it('returns the documented defaults for empty settings', () => { + const cfg = resolveTerminalHistoryConfig({}); + expect(cfg).toEqual({ + terminalScrollbackLines: DEFAULT_TERMINAL_SCROLLBACK_LINES, + tmuxHistoryLimit: DEFAULT_TMUX_HISTORY_LIMIT, + terminalBufferMaxBytes: DEFAULT_TERMINAL_BUFFER_MAX_BYTES, + terminalBufferTrimBytes: DEFAULT_TERMINAL_BUFFER_TRIM_BYTES, + }); + }); + + it('defaults match the prior hardcoded values (behavior-neutral)', () => { + expect(DEFAULT_TMUX_HISTORY_LIMIT).toBe(50_000); + expect(DEFAULT_TERMINAL_SCROLLBACK_LINES).toBe(50_000); + expect(DEFAULT_TERMINAL_BUFFER_MAX_BYTES).toBe(2 * 1024 * 1024); + expect(DEFAULT_TERMINAL_BUFFER_TRIM_BYTES).toBe(1.5 * 1024 * 1024); + }); + + it('passes valid in-range values through unchanged', () => { + const cfg = resolveTerminalHistoryConfig({ + terminalScrollbackLines: 50_000, + tmuxHistoryLimit: 75_000, + terminalBufferMaxBytes: 16 * 1024 * 1024, + terminalBufferTrimBytes: 8 * 1024 * 1024, + }); + expect(cfg).toEqual({ + terminalScrollbackLines: 50_000, + tmuxHistoryLimit: 75_000, + terminalBufferMaxBytes: 16 * 1024 * 1024, + terminalBufferTrimBytes: 8 * 1024 * 1024, + }); + }); + + it('clamps scrollback/history below MIN up to the floor', () => { + const cfg = resolveTerminalHistoryConfig({ + terminalScrollbackLines: 1, + tmuxHistoryLimit: 0, + }); + expect(cfg.terminalScrollbackLines).toBe(MIN_TERMINAL_SCROLLBACK_LINES); + expect(cfg.tmuxHistoryLimit).toBe(MIN_TERMINAL_SCROLLBACK_LINES); + }); + + it('clamps scrollback/history above MAX down to the ceiling', () => { + const cfg = resolveTerminalHistoryConfig({ + terminalScrollbackLines: 999_999_999, + tmuxHistoryLimit: 999_999_999, + }); + expect(cfg.terminalScrollbackLines).toBe(MAX_TERMINAL_SCROLLBACK_LINES); + expect(cfg.tmuxHistoryLimit).toBe(MAX_TERMINAL_SCROLLBACK_LINES); + }); + + it('clamps the buffer byte caps to their MIN/MAX bounds', () => { + const tooSmall = resolveTerminalHistoryConfig({ terminalBufferMaxBytes: 1 }); + expect(tooSmall.terminalBufferMaxBytes).toBe(MIN_TERMINAL_BUFFER_BYTES); + + const tooLarge = resolveTerminalHistoryConfig({ terminalBufferMaxBytes: 1024 * 1024 * 1024 }); + expect(tooLarge.terminalBufferMaxBytes).toBe(MAX_TERMINAL_BUFFER_BYTES); + }); + + it('keeps trim <= max (trim is capped to the resolved max)', () => { + const cfg = resolveTerminalHistoryConfig({ + terminalBufferMaxBytes: 4 * 1024 * 1024, + terminalBufferTrimBytes: 64 * 1024 * 1024, + }); + expect(cfg.terminalBufferTrimBytes).toBeLessThanOrEqual(cfg.terminalBufferMaxBytes); + expect(cfg.terminalBufferTrimBytes).toBe(4 * 1024 * 1024); + }); + + it('caps the default trim to a lowered max', () => { + // Default trim (1.5MB) exceeds a 1MB max → trim must fall to the max. + const cfg = resolveTerminalHistoryConfig({ terminalBufferMaxBytes: 1 * 1024 * 1024 }); + expect(cfg.terminalBufferTrimBytes).toBe(1 * 1024 * 1024); + }); + + it('truncates fractional inputs to integers', () => { + const cfg = resolveTerminalHistoryConfig({ terminalScrollbackLines: 12_345.9 }); + expect(cfg.terminalScrollbackLines).toBe(12_345); + }); + + it('falls back to defaults for non-number / non-finite inputs', () => { + const cfg = resolveTerminalHistoryConfig({ + terminalScrollbackLines: 'lots' as unknown as number, + tmuxHistoryLimit: NaN, + terminalBufferMaxBytes: null as unknown as number, + terminalBufferTrimBytes: Infinity, + }); + expect(cfg.terminalScrollbackLines).toBe(DEFAULT_TERMINAL_SCROLLBACK_LINES); + expect(cfg.tmuxHistoryLimit).toBe(DEFAULT_TMUX_HISTORY_LIMIT); + expect(cfg.terminalBufferMaxBytes).toBe(DEFAULT_TERMINAL_BUFFER_MAX_BYTES); + // trim default is min(DEFAULT_TRIM, resolvedMax) — here resolvedMax is the default max. + expect(cfg.terminalBufferTrimBytes).toBe(DEFAULT_TERMINAL_BUFFER_TRIM_BYTES); + }); + + it('uses an empty object when called with no argument', () => { + expect(resolveTerminalHistoryConfig().tmuxHistoryLimit).toBe(DEFAULT_TMUX_HISTORY_LIMIT); + }); +});