Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
59 changes: 59 additions & 0 deletions packages/cli/src/ui/components/AudioWaveform.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import { render } from '../../test-utils/render.js';
import { describe, it, expect } from 'vitest';
import { AudioWaveform } from './AudioWaveform.js';

describe('<AudioWaveform />', () => {
it('renders nothing when idle', async () => {
const { lastFrame, waitUntilReady } = render(
<AudioWaveform state="idle" />,
);
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })).toBe('');
});

it('renders error state correctly', async () => {
const { lastFrame, waitUntilReady } = render(
<AudioWaveform state="error" width={20} />,
);
await waitUntilReady();
expect(lastFrame()).toContain('Voice session error');
});

it('renders processing state as static bars', async () => {
const { lastFrame, waitUntilReady } = render(
<AudioWaveform state="processing" width={5} />,
);
await waitUntilReady();
expect(lastFrame()).toContain('▅▅▅▅▅');
});

it('renders listening state with mapped amplitudes', async () => {
const { lastFrame, waitUntilReady } = render(
<AudioWaveform state="listening" amplitudes={[0, 0.5, 1]} width={3} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});

it('renders speaking state and truncates/resamples when width does not match amplitudes', async () => {
const { lastFrame, waitUntilReady } = render(
<AudioWaveform state="speaking" amplitudes={[1, 0, 1]} width={6} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});

it('renders empty amplitudes with empty bars correctly', async () => {
const { lastFrame, waitUntilReady } = render(
<AudioWaveform state="speaking" amplitudes={[]} width={5} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
});
77 changes: 77 additions & 0 deletions packages/cli/src/ui/components/AudioWaveform.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import type React from 'react';
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { useMemo } from 'react';

export type VoiceState =
| 'idle'
| 'listening'
| 'processing'
| 'speaking'
| 'error';

export interface AudioWaveformProps {
state: VoiceState;
amplitudes?: number[];
width?: number;
}

const BLOCKS = [' ', '▂', '▃', '▄', '▅', '▆', '▇', '█'];

function getBlock(amplitude: number): string {
if (amplitude <= 0) return BLOCKS[0]!;
if (amplitude >= 1) return BLOCKS[BLOCKS.length - 1]!;
const index = Math.floor(amplitude * BLOCKS.length);
return BLOCKS[Math.min(index, BLOCKS.length - 1)]!;
}

export const AudioWaveform: React.FC<AudioWaveformProps> = ({
state,
amplitudes = [],
width: propsWidth = 40,
}) => {
const width = Math.max(0, Math.floor(propsWidth));
const rendered = useMemo(() => {
if (state === 'idle') {
return null;
}

if (state === 'error') {
return <Text color={theme.status.error}>Voice session error.</Text>;
}

let color: string;
let blocks: string;

if (state === 'processing') {
color = theme.status.warning;
blocks = '▅'.repeat(width);
} else {
color = state === 'speaking' ? theme.text.link : theme.status.success;
const amps = amplitudes.length > 0 ? amplitudes : (Array(width).fill(0) as number[]);
const displayAmps = (Array(width).fill(0) as number[]).map((_, i) => {
const srcIdx = Math.floor((i / width) * amps.length);
return amps[srcIdx] || 0;
});
blocks = displayAmps.map(getBlock).join('');
}

return (
<Text color={color} wrap="truncate">
{blocks}
</Text>
);
}, [state, amplitudes, width]);

if (!rendered) {
return null;
}

return <Box width={width}>{rendered}</Box>;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html

exports[`<AudioWaveform /> > renders empty amplitudes with empty bars correctly 1`] = `
"
"
`;

exports[`<AudioWaveform /> > renders listening state with mapped amplitudes 1`] = `
" ▅█
"
`;

exports[`<AudioWaveform /> > renders speaking state and truncates/resamples when width does not match amplitudes 1`] = `
"██ ██
"
`;
12 changes: 10 additions & 2 deletions packages/core/src/services/shellExecutionService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -731,7 +731,11 @@ describe('ShellExecutionService', () => {

expect(mockPtySpawn).toHaveBeenCalledWith(
'powershell.exe',
['-NoProfile', '-Command', 'dir "foo bar"'],
[
'-NoProfile',
'-Command',
'[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; dir "foo bar"',
],
expect.any(Object),
);
});
Expand Down Expand Up @@ -1250,7 +1254,11 @@ describe('ShellExecutionService child_process fallback', () => {

expect(mockCpSpawn).toHaveBeenCalledWith(
'powershell.exe',
['-NoProfile', '-Command', 'dir "foo bar"'],
[
'-NoProfile',
'-Command',
'[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; dir "foo bar"',
],
expect.objectContaining({
shell: false,
detached: false,
Expand Down
7 changes: 5 additions & 2 deletions packages/core/src/services/shellExecutionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { getCachedEncodingForBuffer } from '../utils/systemEncoding.js';
import {
getShellConfiguration,
resolveExecutable,
ensurePowerShellUtf8Encoding,
type ShellType,
} from '../utils/shell-utils.js';
import { isBinary } from '../utils/textUtils.js';
Expand Down Expand Up @@ -302,7 +303,8 @@ export class ShellExecutionService {
try {
const isWindows = os.platform() === 'win32';
const { executable, argsPrefix, shell } = getShellConfiguration();
const guardedCommand = ensurePromptvarsDisabled(commandToExecute, shell);
let guardedCommand = ensurePromptvarsDisabled(commandToExecute, shell);
guardedCommand = ensurePowerShellUtf8Encoding(guardedCommand, shell);
const spawnArgs = [...argsPrefix, guardedCommand];

const child = cpSpawn(executable, spawnArgs, {
Expand Down Expand Up @@ -564,7 +566,8 @@ export class ShellExecutionService {
);
}

const guardedCommand = ensurePromptvarsDisabled(commandToExecute, shell);
let guardedCommand = ensurePromptvarsDisabled(commandToExecute, shell);
guardedCommand = ensurePowerShellUtf8Encoding(guardedCommand, shell);
const args = [...argsPrefix, guardedCommand];

// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
Expand Down
27 changes: 27 additions & 0 deletions packages/core/src/utils/shell-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
stripShellWrapper,
hasRedirection,
resolveExecutable,
ensurePowerShellUtf8Encoding,
} from './shell-utils.js';
import path from 'node:path';

Expand Down Expand Up @@ -339,6 +340,32 @@ describe('stripShellWrapper', () => {
});
});

describe('ensurePowerShellUtf8Encoding', () => {
const utf8Prefix =
'[Console]::OutputEncoding = [System.Text.Encoding]::UTF8;';

it('should not modify command for non-powershell shells', () => {
expect(ensurePowerShellUtf8Encoding('echo "test"', 'bash')).toEqual(
'echo "test"',
);
expect(ensurePowerShellUtf8Encoding('echo "test"', 'cmd')).toEqual(
'echo "test"',
);
});

it('should prepend utf8 configuration for powershell', () => {
expect(ensurePowerShellUtf8Encoding('echo "test"', 'powershell')).toEqual(
`${utf8Prefix} echo "test"`,
);
});

it('should not double-prepend if already present', () => {
expect(
ensurePowerShellUtf8Encoding(`${utf8Prefix} echo "test"`, 'powershell'),
).toEqual(`${utf8Prefix} echo "test"`);
});
});

describe('escapeShellArg', () => {
describe('POSIX (bash)', () => {
it('should use shell-quote for escaping', () => {
Expand Down
28 changes: 28 additions & 0 deletions packages/core/src/utils/shell-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -603,6 +603,34 @@ export function escapeShellArg(arg: string, shell: ShellType): string {
}
}

/**
* For PowerShell, we must explicitly configure the console output encoding to UTF-8
* to prevent Node.js from misinterpreting characters emitted by child processes,
* especially on Windows systems with different default code pages.
*
* @param command The command string to execute
* @param shell The type of shell being used
* @returns The command string, prefixed with the UTF-8 encoding configuration if PowerShell
*/
export function ensurePowerShellUtf8Encoding(
command: string,
shell: ShellType,
): string {
if (shell !== 'powershell') {
return command;
}

const utf8Config =
'[Console]::OutputEncoding = [System.Text.Encoding]::UTF8;';

const trimmed = command.trimStart();
if (trimmed.startsWith(utf8Config)) {
return command;
}

return `${utf8Config} ${command}`;
}

/**
* Splits a shell command into a list of individual commands, respecting quotes.
* This is used to separate chained commands (e.g., using &&, ||, ;).
Expand Down