File size: 5,917 Bytes
52efc7b | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 | /**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { AnsiOutput, CompletionBehavior } from '@google/gemini-cli-core';
import {
MAX_SHELL_OUTPUT_SIZE,
SHELL_OUTPUT_TRUNCATION_BUFFER,
} from '../constants.js';
export interface BackgroundTask {
pid: number;
command: string;
output: string | AnsiOutput;
isBinary: boolean;
binaryBytesReceived: number;
status: 'running' | 'exited';
exitCode?: number;
completionBehavior?: CompletionBehavior;
}
export interface ShellState {
activeShellPtyId: number | null;
lastShellOutputTime: number;
backgroundTasks: Map<number, BackgroundTask>;
isBackgroundTaskVisible: boolean;
}
export type ShellAction =
| { type: 'SET_ACTIVE_PTY'; pid: number | null }
| { type: 'SET_OUTPUT_TIME'; time: number }
| { type: 'SET_VISIBILITY'; visible: boolean }
| { type: 'TOGGLE_VISIBILITY' }
| {
type: 'REGISTER_TASK';
pid: number;
command: string;
initialOutput: string | AnsiOutput;
completionBehavior?: CompletionBehavior;
}
| { type: 'UPDATE_TASK'; pid: number; update: Partial<BackgroundTask> }
| { type: 'APPEND_TASK_OUTPUT'; pid: number; chunk: string | AnsiOutput }
| { type: 'SYNC_BACKGROUND_TASKS' }
| { type: 'DISMISS_TASK'; pid: number };
export const initialState: ShellState = {
activeShellPtyId: null,
lastShellOutputTime: 0,
backgroundTasks: new Map(),
isBackgroundTaskVisible: false,
};
export function shellReducer(
state: ShellState,
action: ShellAction,
): ShellState {
switch (action.type) {
case 'SET_ACTIVE_PTY':
return { ...state, activeShellPtyId: action.pid };
case 'SET_OUTPUT_TIME':
return { ...state, lastShellOutputTime: action.time };
case 'SET_VISIBILITY':
return { ...state, isBackgroundTaskVisible: action.visible };
case 'TOGGLE_VISIBILITY':
return {
...state,
isBackgroundTaskVisible: !state.isBackgroundTaskVisible,
};
case 'REGISTER_TASK': {
if (state.backgroundTasks.has(action.pid)) return state;
const nextTasks = new Map(state.backgroundTasks);
nextTasks.set(action.pid, {
pid: action.pid,
command: action.command,
output: action.initialOutput,
isBinary: false,
binaryBytesReceived: 0,
status: 'running',
completionBehavior: action.completionBehavior,
});
return { ...state, backgroundTasks: nextTasks };
}
case 'UPDATE_TASK': {
const task = state.backgroundTasks.get(action.pid);
if (!task) return state;
const nextTasks = new Map(state.backgroundTasks);
const updatedTask = { ...task, ...action.update };
// Maintain insertion order, move to end if status changed to exited
if (action.update.status === 'exited') {
nextTasks.delete(action.pid);
}
nextTasks.set(action.pid, updatedTask);
return { ...state, backgroundTasks: nextTasks };
}
case 'APPEND_TASK_OUTPUT': {
const task = state.backgroundTasks.get(action.pid);
if (!task) return state;
// Note: we mutate the task object in the map for background updates
// to avoid re-rendering if the drawer is not visible.
// This is an intentional performance optimization for the CLI.
let newOutput = task.output;
if (typeof action.chunk === 'string') {
// Check combined length BEFORE concatenation — the + operator itself
// can throw if the resulting string would exceed ~1 GB.
const currentOutput =
typeof task.output === 'string' ? task.output : '';
const combinedLength = currentOutput.length + action.chunk.length;
if (
combinedLength >
MAX_SHELL_OUTPUT_SIZE + SHELL_OUTPUT_TRUNCATION_BUFFER
) {
if (action.chunk.length >= MAX_SHELL_OUTPUT_SIZE) {
// Incoming chunk alone exceeds the cap — keep its tail.
newOutput = action.chunk.slice(-MAX_SHELL_OUTPUT_SIZE);
} else {
// Keep as much of the existing output as possible, then append.
const keepFromCurrent = MAX_SHELL_OUTPUT_SIZE - action.chunk.length;
newOutput = currentOutput.slice(-keepFromCurrent) + action.chunk;
}
// Native slice operates on UTF-16 code units, so it may split a
// surrogate pair at the truncation boundary. If the first code unit
// of the result is a low surrogate (\uDC00-\uDFFF), trim it off to
// avoid emitting a broken character.
if (newOutput.length > 0) {
const firstCharCode = newOutput.charCodeAt(0);
if (firstCharCode >= 0xdc00 && firstCharCode <= 0xdfff) {
newOutput = newOutput.slice(1);
}
}
} else {
newOutput = currentOutput + action.chunk;
}
} else if (action.chunk) {
// AnsiOutput replaces the whole buffer.
newOutput = action.chunk;
}
// If action.chunk is falsy (e.g. empty string already handled above via
// typeof gate), newOutput remains unchanged — no data loss.
task.output = newOutput;
const nextState = { ...state, lastShellOutputTime: Date.now() };
if (state.isBackgroundTaskVisible) {
return {
...nextState,
backgroundTasks: new Map(state.backgroundTasks),
};
}
return nextState;
}
case 'SYNC_BACKGROUND_TASKS': {
return { ...state, backgroundTasks: new Map(state.backgroundTasks) };
}
case 'DISMISS_TASK': {
const nextTasks = new Map(state.backgroundTasks);
nextTasks.delete(action.pid);
return {
...state,
backgroundTasks: nextTasks,
isBackgroundTaskVisible:
nextTasks.size === 0 ? false : state.isBackgroundTaskVisible,
};
}
default:
return state;
}
}
|