Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
3 changes: 3 additions & 0 deletions .gemini/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,8 @@
"toolOutputMasking": {
"enabled": true
}
},
"general": {
"devtools": true
}
}
4 changes: 4 additions & 0 deletions docs/get-started/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,10 @@ their corresponding top-level category object in your `settings.json` file.
- **Description:** Enable Vim keybindings
- **Default:** `false`

- **`general.devtools`** (boolean):
- **Description:** Enable DevTools inspector on launch.
- **Default:** `false`

- **`general.enableAutoUpdate`** (boolean):
- **Description:** Enable automatic updates.
- **Default:** `true`
Expand Down
1 change: 1 addition & 0 deletions esbuild.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ const external = [
'@lydell/node-pty-win32-arm64',
'@lydell/node-pty-win32-x64',
'keytar',
'gemini-cli-devtools',
];

const baseConfig = {
Expand Down
13 changes: 13 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@
"@lydell/node-pty-linux-x64": "1.1.0",
"@lydell/node-pty-win32-arm64": "1.1.0",
"@lydell/node-pty-win32-x64": "1.1.0",
"gemini-cli-devtools": "^0.2.1",
"keytar": "^7.9.0",
"node-pty": "^1.0.0"
},
Expand Down
9 changes: 9 additions & 0 deletions packages/cli/src/config/settingsSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,15 @@ const SETTINGS_SCHEMA = {
description: 'Enable Vim keybindings',
showInDialog: true,
},
devtools: {
type: 'boolean',
label: 'DevTools',
category: 'General',
requiresRestart: false,
default: false,
description: 'Enable DevTools inspector on launch.',
showInDialog: false,
},
enableAutoUpdate: {
type: 'boolean',
label: 'Enable Auto Update',
Expand Down
18 changes: 14 additions & 4 deletions packages/cli/src/gemini.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -351,11 +351,12 @@ export async function main() {
}

const isDebugMode = cliConfig.isDebugMode(argv);
const earlyConsoleLogs: Array<{ type: string; content: string }> = [];
const consolePatcher = new ConsolePatcher({
stderr: true,
debugMode: isDebugMode,
onNewMessage: (msg) => {
coreEvents.emitConsoleLog(msg.type, msg.content);
earlyConsoleLogs.push({ type: msg.type, content: msg.content });
},
});
consolePatcher.patch();
Expand Down Expand Up @@ -518,12 +519,21 @@ export async function main() {

adminControlsListner.setConfig(config);

if (config.isInteractive() && config.getDebugMode()) {
if (config.isInteractive() && settings.merged.general.devtools) {
const { registerActivityLogger } = await import(
'./utils/activityLogger.js'
'./utils/devtoolsService.js'
);
registerActivityLogger(config);
await registerActivityLogger(config);
// Replay early console logs directly to ActivityLogger (bypasses
// coreEvents so they only appear in DevTools, not the TUI debug console).
const { ActivityLogger } = await import('./utils/activityLogger.js');
const capture = ActivityLogger.getInstance();
for (const log of earlyConsoleLogs) {
capture.logConsole(log);
}
}
// Always clear early logs to avoid unbounded memory growth
earlyConsoleLogs.length = 0;

// Register config for telemetry shutdown
// This ensures telemetry (including SessionEnd hooks) is properly flushed on exit
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/nonInteractiveCli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ import type { LoadedSettings } from './config/settings.js';
vi.mock('./ui/hooks/atCommandProcessor.js');

const mockRegisterActivityLogger = vi.hoisted(() => vi.fn());
vi.mock('./utils/activityLogger.js', () => ({
vi.mock('./utils/devtoolsService.js', () => ({
registerActivityLogger: mockRegisterActivityLogger,
}));

Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/nonInteractiveCli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,9 @@ export async function runNonInteractive({

if (process.env['GEMINI_CLI_ACTIVITY_LOG_TARGET']) {
const { registerActivityLogger } = await import(
'./utils/activityLogger.js'
'./utils/devtoolsService.js'
);
registerActivityLogger(config);
await registerActivityLogger(config);
}

const { stdout: workingStdout } = createWorkingStdio();
Expand Down
116 changes: 65 additions & 51 deletions packages/cli/src/utils/activityLogger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,29 +21,6 @@ import WebSocket from 'ws';
const ACTIVITY_ID_HEADER = 'x-activity-request-id';
const MAX_BUFFER_SIZE = 100;

/**
* Parse a host:port string into its components.
* Uses the URL constructor for robust handling of IPv4, IPv6, and hostnames.
* Returns null for file paths or values without a valid port.
*/
function parseHostPort(value: string): { host: string; port: number } | null {
if (value.startsWith('/') || value.startsWith('.')) return null;

try {
const url = new URL(`ws://${value}`);
if (!url.port) return null;

const port = parseInt(url.port, 10);
if (url.hostname && !isNaN(port) && port > 0 && port <= 65535) {
return { host: url.hostname, port };
}
} catch {
// Not a valid host:port
}

return null;
}

export interface NetworkLog {
id: string;
timestamp: number;
Expand Down Expand Up @@ -494,19 +471,23 @@ function setupNetworkLogging(
host: string,
port: number,
config: Config,
onReconnectFailed?: () => void,
) {
const buffer: Array<Record<string, unknown>> = [];
let ws: WebSocket | null = null;
let reconnectTimer: NodeJS.Timeout | null = null;
let sessionId: string | null = null;
let pingInterval: NodeJS.Timeout | null = null;
let reconnectAttempts = 0;
const MAX_RECONNECT_ATTEMPTS = 2;

const connect = () => {
try {
ws = new WebSocket(`ws://${host}:${port}/ws`);

ws.on('open', () => {
debugLogger.debug(`WebSocket connected to ${host}:${port}`);
reconnectAttempts = 0;
// Register with CLI's session ID
sendMessage({
type: 'register',
Expand Down Expand Up @@ -620,11 +601,20 @@ function setupNetworkLogging(
const scheduleReconnect = () => {
if (reconnectTimer) return;

reconnectAttempts++;
if (reconnectAttempts > MAX_RECONNECT_ATTEMPTS && onReconnectFailed) {
debugLogger.debug(
`WebSocket reconnect failed after ${MAX_RECONNECT_ATTEMPTS} attempts, promoting to server...`,
);
onReconnectFailed();
return;
}

reconnectTimer = setTimeout(() => {
reconnectTimer = null;
debugLogger.debug('Reconnecting WebSocket...');
connect();
}, 5000);
}, 1000);
};

// Initial connection
Expand All @@ -645,41 +635,65 @@ function setupNetworkLogging(
});
}

let bridgeAttached = false;

/**
* Registers the activity logger if debug mode and interactive session are enabled.
* Captures network and console logs to a session-specific JSONL file or sends to network.
*
* Environment variable GEMINI_CLI_ACTIVITY_LOG_TARGET controls the output:
* - host:port format (e.g., "localhost:25417") → network mode (auto-enabled)
* - file path (e.g., "/tmp/logs.jsonl") → file mode (immediate)
* - not set → uses default file location in project temp logs dir
*
* @param config The CLI configuration
* Bridge coreEvents to the ActivityLogger singleton (guarded — only once).
*/
export function registerActivityLogger(config: Config) {
const target = process.env['GEMINI_CLI_ACTIVITY_LOG_TARGET'];
const hostPort = target ? parseHostPort(target) : null;

// Network mode doesn't need storage; file mode does
if (!hostPort && !config.storage) {
return;
}
function bridgeCoreEvents(capture: ActivityLogger) {
if (bridgeAttached) return;
bridgeAttached = true;
coreEvents.on(CoreEvent.ConsoleLog, (payload) => {
capture.logConsole(payload);
});
}

/**
* Initialize the activity logger with a specific transport mode.
*
* @param config CLI configuration
* @param options Transport configuration: network (WebSocket) or file (JSONL)
*/
export function initActivityLogger(
config: Config,
options:
| {
mode: 'network';
host: string;
port: number;
onReconnectFailed?: () => void;
}
| { mode: 'file'; filePath?: string },
): void {
const capture = ActivityLogger.getInstance();
capture.enable();

if (hostPort) {
// Network mode: send logs via WebSocket
setupNetworkLogging(capture, hostPort.host, hostPort.port, config);
// Auto-enable network logging when target is explicitly configured
if (options.mode === 'network') {
setupNetworkLogging(
capture,
options.host,
options.port,
config,
options.onReconnectFailed,
);
capture.enableNetworkLogging();
} else {
// File mode: write to JSONL file
setupFileLogging(capture, config, target);
setupFileLogging(capture, config, options.filePath);
}

// Bridge CoreEvents to local capture
coreEvents.on(CoreEvent.ConsoleLog, (payload) => {
capture.logConsole(payload);
});
bridgeCoreEvents(capture);
}

/**
* Add a network (WebSocket) transport to the existing ActivityLogger singleton.
* Used for promotion re-entry without re-bridging coreEvents.
*/
export function addNetworkTransport(
config: Config,
host: string,
port: number,
onReconnectFailed?: () => void,
): void {
const capture = ActivityLogger.getInstance();
setupNetworkLogging(capture, host, port, config, onReconnectFailed);
}
Loading
Loading