mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
Merge PR #137: Centralize terminal history/scrollback/buffer limits into config (COD-80)
Introduces src/config/terminal-history.ts as the single source of truth for terminal scrollback lines, tmux history-limit, and PTY buffer byte caps. Behavior-neutral: defaults match prior hardcoded values; env overrides preserved. tmuxHistoryLimit is wired live (setHistoryLimit + respawn re-apply); the other three keys are scaffolding for a stacked follow-up. Reviewed: CI green (typecheck/lint + full test suite).
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<string, unknown> = {}): 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,
|
||||
};
|
||||
}
|
||||
@@ -72,6 +72,8 @@ export interface CreateSessionOptions {
|
||||
envOverrides?: Record<string, string>;
|
||||
/** 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<string, string>;
|
||||
/** 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<void>;
|
||||
|
||||
// ========== Discovery ==========
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<string, string>;
|
||||
/** 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',
|
||||
});
|
||||
|
||||
+36
-1
@@ -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<void> {
|
||||
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`.
|
||||
*
|
||||
|
||||
@@ -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<NiceConfig | undefined>;
|
||||
getModelConfig(): Promise<{ defaultModel?: string; agentTypeOverrides?: Record<string, string> } | null>;
|
||||
getClaudeModeConfig(): Promise<{ claudeMode?: ClaudeMode; allowedTools?: string }>;
|
||||
getTerminalHistoryConfig(): Promise<TerminalHistoryConfig>;
|
||||
getDefaultClaudeMdPath(): Promise<string | undefined>;
|
||||
getLightState(): unknown;
|
||||
getLightSessionsState(): unknown[];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
+30
-1
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user