feat(cli): implement automatic theme switching based on terminal background (#17976)

Co-authored-by: Jacob Richman <jacob314@gmail.com>
This commit is contained in:
Abhijit Balaji
2026-02-02 16:39:17 -08:00
committed by GitHub
parent f57fd642df
commit 4e4a55be35
18 changed files with 807 additions and 93 deletions

View File

@@ -0,0 +1,81 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from '../../test-utils/render.js';
import { TerminalProvider, useTerminalContext } from './TerminalContext.js';
import { vi, describe, it, expect, type Mock } from 'vitest';
import { useEffect, act } from 'react';
import { EventEmitter } from 'node:events';
import { waitFor } from '../../test-utils/async.js';
const mockStdin = new EventEmitter() as unknown as NodeJS.ReadStream &
EventEmitter;
// Add required properties for Ink's StdinProps
(mockStdin as unknown as { write: Mock }).write = vi.fn();
(mockStdin as unknown as { setEncoding: Mock }).setEncoding = vi.fn();
(mockStdin as unknown as { setRawMode: Mock }).setRawMode = vi.fn();
(mockStdin as unknown as { isTTY: boolean }).isTTY = true;
// Mock removeListener specifically as it is used in cleanup
(mockStdin as unknown as { removeListener: Mock }).removeListener = vi.fn(
(event: string, listener: (...args: unknown[]) => void) => {
mockStdin.off(event, listener);
},
);
vi.mock('ink', () => ({
useStdin: () => ({
stdin: mockStdin,
}),
}));
const TestComponent = ({ onColor }: { onColor: (c: string) => void }) => {
const { subscribe } = useTerminalContext();
useEffect(() => {
subscribe(onColor);
}, [subscribe, onColor]);
return null;
};
describe('TerminalContext', () => {
it('should parse OSC 11 response', async () => {
const handleColor = vi.fn();
render(
<TerminalProvider>
<TestComponent onColor={handleColor} />
</TerminalProvider>,
);
act(() => {
mockStdin.emit('data', '\x1b]11;rgb:ffff/ffff/ffff\x1b\\');
});
await waitFor(() => {
expect(handleColor).toHaveBeenCalledWith('rgb:ffff/ffff/ffff');
});
});
it('should handle partial chunks', async () => {
const handleColor = vi.fn();
render(
<TerminalProvider>
<TestComponent onColor={handleColor} />
</TerminalProvider>,
);
act(() => {
mockStdin.emit('data', '\x1b]11;rgb:0000/');
});
expect(handleColor).not.toHaveBeenCalled();
act(() => {
mockStdin.emit('data', '0000/0000\x1b\\');
});
await waitFor(() => {
expect(handleColor).toHaveBeenCalledWith('rgb:0000/0000/0000');
});
});
});

View File

@@ -0,0 +1,96 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { useStdin } from 'ink';
import type React from 'react';
import {
createContext,
useCallback,
useContext,
useEffect,
useRef,
} from 'react';
import { TerminalCapabilityManager } from '../utils/terminalCapabilityManager.js';
export type TerminalEventHandler = (event: string) => void;
interface TerminalContextValue {
subscribe: (handler: TerminalEventHandler) => void;
unsubscribe: (handler: TerminalEventHandler) => void;
}
const TerminalContext = createContext<TerminalContextValue | undefined>(
undefined,
);
export function useTerminalContext() {
const context = useContext(TerminalContext);
if (!context) {
throw new Error(
'useTerminalContext must be used within a TerminalProvider',
);
}
return context;
}
export function TerminalProvider({ children }: { children: React.ReactNode }) {
const { stdin } = useStdin();
const subscribers = useRef<Set<TerminalEventHandler>>(new Set()).current;
const bufferRef = useRef('');
const subscribe = useCallback(
(handler: TerminalEventHandler) => {
subscribers.add(handler);
},
[subscribers],
);
const unsubscribe = useCallback(
(handler: TerminalEventHandler) => {
subscribers.delete(handler);
},
[subscribers],
);
useEffect(() => {
const handleData = (data: Buffer | string) => {
bufferRef.current +=
typeof data === 'string' ? data : data.toString('utf-8');
// Check for OSC 11 response
const match = bufferRef.current.match(
TerminalCapabilityManager.OSC_11_REGEX,
);
if (match) {
const colorStr = `rgb:${match[1]}/${match[2]}/${match[3]}`;
for (const handler of subscribers) {
handler(colorStr);
}
// Safely remove the processed part + match
if (match.index !== undefined) {
bufferRef.current = bufferRef.current.slice(
match.index + match[0].length,
);
}
} else if (bufferRef.current.length > 4096) {
// Safety valve: if buffer gets too large without a match, trim it.
// We keep the last 1024 bytes to avoid cutting off a partial sequence.
bufferRef.current = bufferRef.current.slice(-1024);
}
};
stdin.on('data', handleData);
return () => {
stdin.removeListener('data', handleData);
};
}, [stdin, subscribers]);
return (
<TerminalContext.Provider value={{ subscribe, unsubscribe }}>
{children}
</TerminalContext.Provider>
);
}