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;
  }
}