File size: 13,048 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
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
/**
 * @license
 * Copyright 2025 Google LLC
 * SPDX-License-Identifier: Apache-2.0
 */

import React, { act } from 'react';
import {
  ShellToolMessage,
  type ShellToolMessageProps,
} from './ShellToolMessage.js';
import { StreamingState } from '../../types.js';
import {
  type Config,
  SHELL_TOOL_NAME,
  CoreToolCallStatus,
} from '@google/gemini-cli-core';
import { renderWithProviders } from '../../../test-utils/render.js';
import { createMockSettings } from '../../../test-utils/settings.js';
import { makeFakeConfig } from '@google/gemini-cli-core';
import { waitFor } from '../../../test-utils/async.js';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { SHELL_COMMAND_NAME, ACTIVE_SHELL_MAX_LINES } from '../../constants.js';
import {
  SHELL_CONTENT_OVERHEAD,
  TOOL_RESULT_STANDARD_RESERVED_LINE_COUNT,
} from '../../utils/toolLayoutUtils.js';

describe('<ShellToolMessage />', () => {
  const baseProps: ShellToolMessageProps = {
    callId: 'tool-123',
    name: SHELL_COMMAND_NAME,
    description: 'A shell command',
    resultDisplay: 'Test result',
    status: CoreToolCallStatus.Executing,
    terminalWidth: 80,
    confirmationDetails: undefined,
    emphasis: 'medium',
    isFirst: true,
    borderColor: 'green',
    borderDimColor: false,
    isExpandable: false,
    config: {
      getEnableInteractiveShell: () => true,
    } as unknown as Config,
  };

  const LONG_OUTPUT = Array.from(
    { length: 100 },
    (_, i) => `Line ${i + 1}`,
  ).join('\n');

  const mockSetEmbeddedShellFocused = vi.fn();
  const uiActions = {
    setEmbeddedShellFocused: mockSetEmbeddedShellFocused,
  };

  beforeEach(() => {
    vi.clearAllMocks();
    vi.useFakeTimers();
  });

  afterEach(() => {
    vi.useRealTimers();
  });

  describe('interactive shell focus', () => {
    it.each([
      ['SHELL_COMMAND_NAME', SHELL_COMMAND_NAME],
      ['SHELL_TOOL_NAME', SHELL_TOOL_NAME],
    ])('clicks inside the shell area sets focus for %s', async (_, name) => {
      const { lastFrame, simulateClick, unmount, waitUntilReady } =
        await renderWithProviders(
          <ShellToolMessage {...baseProps} name={name} />,
          { uiActions, mouseEventsEnabled: true },
        );

      await waitUntilReady();
      expect(lastFrame()).toContain('A shell command');

      await simulateClick(2, 2);

      await waitFor(() => {
        expect(mockSetEmbeddedShellFocused).toHaveBeenCalledWith(true);
      });
      unmount();
    });

    it('resets focus when shell finishes', async () => {
      let updateStatus: (s: CoreToolCallStatus) => void = () => {};

      const Wrapper = () => {
        const [status, setStatus] = React.useState(
          CoreToolCallStatus.Executing,
        );
        updateStatus = setStatus;
        return <ShellToolMessage {...baseProps} status={status} ptyId={1} />;
      };

      const { lastFrame, unmount, waitUntilReady } = await renderWithProviders(
        <Wrapper />,
        {
          uiActions,
          uiState: {
            streamingState: StreamingState.Idle,
            embeddedShellFocused: true,
            activePtyId: 1,
          },
        },
      );

      // Verify it is initially focused
      await waitUntilReady();
      expect(lastFrame()).toContain('(Shift+Tab to unfocus)');

      // Now update status to Success
      await act(async () => {
        updateStatus(CoreToolCallStatus.Success);
      });

      // Should call setEmbeddedShellFocused(false) because isThisShellFocused became false
      await waitFor(() => {
        expect(mockSetEmbeddedShellFocused).toHaveBeenCalledWith(false);
        expect(lastFrame()).not.toContain('(Shift+Tab to unfocus)');
      });
      unmount();
    });
  });

  describe('Snapshots', () => {
    it.each([
      [
        'renders in Executing state',
        { status: CoreToolCallStatus.Executing },
        undefined,
      ],
      [
        'renders in Success state (history mode)',
        { status: CoreToolCallStatus.Success },
        undefined,
      ],
      [
        'renders in Error state',
        { status: CoreToolCallStatus.Error, resultDisplay: 'Error output' },
        undefined,
      ],
      [
        'renders in Cancelled state with partial output',
        {
          status: CoreToolCallStatus.Cancelled,
          resultDisplay: 'Partial output before cancellation',
        },
        undefined,
      ],
      [
        'renders in Alternate Buffer mode while focused',
        {
          status: CoreToolCallStatus.Executing,
          ptyId: 1,
        },
        {
          config: makeFakeConfig({ useAlternateBuffer: true }),
          settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
          uiState: {
            embeddedShellFocused: true,
            activePtyId: 1,
          },
        },
      ],
      [
        'renders in Alternate Buffer mode while unfocused',
        {
          status: CoreToolCallStatus.Executing,
          ptyId: 1,
        },
        {
          config: makeFakeConfig({ useAlternateBuffer: true }),
          settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
          uiState: {
            embeddedShellFocused: false,
            activePtyId: 1,
          },
        },
      ],
    ])('%s', async (_, props, options) => {
      const { lastFrame, unmount } = await renderWithProviders(
        <ShellToolMessage {...baseProps} {...props} />,
        { uiActions, ...options },
      );
      expect(lastFrame()).toMatchSnapshot();
      unmount();
    });
  });

  describe('Height Constraints', () => {
    it.each([
      [
        'respects availableTerminalHeight when it is smaller than ACTIVE_SHELL_MAX_LINES',
        10,
        10 - TOOL_RESULT_STANDARD_RESERVED_LINE_COUNT, // 7 (Header height is 3, but calculation uses reserved=3)
        false,
        true,
        false,
      ],
      [
        'uses ACTIVE_SHELL_MAX_LINES when availableTerminalHeight is large',
        100,
        ACTIVE_SHELL_MAX_LINES - SHELL_CONTENT_OVERHEAD, // 11
        false,
        true,
        false,
      ],
      [
        'uses full availableTerminalHeight when focused in alternate buffer mode',
        100,
        100 - TOOL_RESULT_STANDARD_RESERVED_LINE_COUNT, // 97
        true,
        false,
        false,
      ],
      [
        'defaults to ACTIVE_SHELL_MAX_LINES in alternate buffer when availableTerminalHeight is undefined',
        undefined,
        ACTIVE_SHELL_MAX_LINES - SHELL_CONTENT_OVERHEAD, // 11
        false,
        true,
        false,
      ],
    ])(
      '%s',
      async (
        _,
        availableTerminalHeight,
        expectedMaxLines,
        focused,
        constrainHeight,
        isExpandable,
      ) => {
        const { lastFrame, waitUntilReady, unmount } =
          await renderWithProviders(
            <ShellToolMessage
              {...baseProps}
              resultDisplay={LONG_OUTPUT}
              renderOutputAsMarkdown={false}
              availableTerminalHeight={availableTerminalHeight}
              ptyId={1}
              status={CoreToolCallStatus.Executing}
              isExpandable={isExpandable}
            />,
            {
              uiActions,
              config: makeFakeConfig({ useAlternateBuffer: true }),
              settings: createMockSettings({
                ui: { useAlternateBuffer: true },
              }),
              uiState: {
                activePtyId: focused ? 1 : 2,
                embeddedShellFocused: focused,
                constrainHeight,
              },
            },
          );

        await waitUntilReady();

        const frame = lastFrame();
        expect(frame.match(/Line \d+/g)?.length).toBe(expectedMaxLines);
        expect(frame).toMatchSnapshot();
        unmount();
      },
    );

    it('fully expands in standard mode when availableTerminalHeight is undefined', async () => {
      const { lastFrame, unmount, waitUntilReady } = await renderWithProviders(
        <ShellToolMessage
          {...baseProps}
          resultDisplay={LONG_OUTPUT}
          renderOutputAsMarkdown={false}
          availableTerminalHeight={undefined}
          status={CoreToolCallStatus.Executing}
        />,
        {
          uiActions,
          config: makeFakeConfig({ useAlternateBuffer: false }),
          settings: createMockSettings({ ui: { useAlternateBuffer: false } }),
          uiState: {
            constrainHeight: false,
            terminalHeight: 200,
          },
        },
      );

      await waitUntilReady();
      const frame = lastFrame();
      // Since it's Executing, it might still constrain to ACTIVE_SHELL_MAX_LINES (10)
      // Actually let's just assert on the behaviour that happens right now (which is 100 lines because we removed the terminalBuffer check)
      expect(frame.match(/Line \d+/g)?.length).toBe(100);
      unmount();
    });

    it('fully expands in alternate buffer mode when constrainHeight is false and isExpandable is true', async () => {
      const { lastFrame, unmount, waitUntilReady } = await renderWithProviders(
        <ShellToolMessage
          {...baseProps}
          resultDisplay={LONG_OUTPUT}
          renderOutputAsMarkdown={false}
          availableTerminalHeight={undefined}
          status={CoreToolCallStatus.Success}
          isExpandable={true}
        />,
        {
          uiActions,
          config: makeFakeConfig({ useAlternateBuffer: true }),
          settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
          uiState: {
            constrainHeight: false,
          },
        },
      );

      await waitUntilReady();
      const frame = lastFrame();
      // Should show all 100 lines because constrainHeight is false and isExpandable is true
      expect(frame.match(/Line \d+/g)?.length).toBe(100);
      expect(lastFrame()).toMatchSnapshot();
      unmount();
    });

    it('stays constrained in alternate buffer mode when isExpandable is false even if constrainHeight is false', async () => {
      const { lastFrame, unmount, waitUntilReady } = await renderWithProviders(
        <ShellToolMessage
          {...baseProps}
          resultDisplay={LONG_OUTPUT}
          renderOutputAsMarkdown={false}
          availableTerminalHeight={undefined}
          status={CoreToolCallStatus.Success}
          isExpandable={false}
        />,
        {
          uiActions,
          config: makeFakeConfig({ useAlternateBuffer: true }),
          settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
          uiState: {
            constrainHeight: false,
          },
        },
      );

      await waitUntilReady();
      const frame = lastFrame();
      // Should still be constrained to 11 (15 - 4) because isExpandable is false
      expect(frame.match(/Line \d+/g)?.length).toBe(
        ACTIVE_SHELL_MAX_LINES - SHELL_CONTENT_OVERHEAD,
      );
      expect(lastFrame()).toMatchSnapshot();
      unmount();
    });
  });

  describe('Header Expansion', () => {
    const LONG_DESCRIPTION = 'very long '.repeat(20);

    it('truncates header by default', async () => {
      const { lastFrame, waitUntilReady } = await renderWithProviders(
        <ShellToolMessage
          {...baseProps}
          description={LONG_DESCRIPTION}
          availableTerminalHeight={10}
        />,
        { uiActions },
      );

      await waitUntilReady();
      const output = lastFrame();
      // Should be a single line header
      expect(output.split('\n')[1]).toContain(SHELL_COMMAND_NAME); // name
      // We check if it's truncated. In our ToolInfo, it's height 1.
      // The StickyHeader adds some structure, but the ToolInfo Box is inside.
    });

    it('expands header when availableTerminalHeight is undefined', async () => {
      const { lastFrame, waitUntilReady } = await renderWithProviders(
        <ShellToolMessage
          {...baseProps}
          description={LONG_DESCRIPTION}
          availableTerminalHeight={undefined}
        />,
        { uiActions },
      );

      await waitUntilReady();
      const output = lastFrame();
      // When expanded, the header (ToolInfo) should wrap and take multiple lines.
      // Since it's at the top, we check if the first few lines contain parts of the description.
      const lines = output.split('\n');
      expect(lines.length).toBeGreaterThan(5);
    });

    it('expands header when isExpanded is true in context', async () => {
      const { lastFrame, waitUntilReady } = await renderWithProviders(
        <ShellToolMessage
          {...baseProps}
          description={LONG_DESCRIPTION}
          availableTerminalHeight={10}
        />,
        {
          uiActions,
          toolActions: {
            isExpanded: (id: string) => id === baseProps.callId,
          },
        },
      );

      await waitUntilReady();
      const output = lastFrame();
      // Should be expanded due to context
      expect(output.split('\n').length).toBeGreaterThan(5);
    });
  });
});