From b9a9c9dfb7251be419fb3e16cc377657d7e66487 Mon Sep 17 00:00:00 2001 From: Chris Tracey Date: Fri, 13 Mar 2026 22:03:15 +1100 Subject: [PATCH] feat: add a global minimalist mode that defaults widgets to raw mode Adds the foundational plumbing for a global minimalist mode toggle: - RenderContext.minimalist flag (optional boolean) - Settings.minimalistMode field (default false, Zod-managed) - Threads the flag from settings into RenderContext at render time in both piped mode (ccstatusline.ts) and TUI preview (StatusLinePreview.tsx) Adds an (m) keybind to the Global Overrides TUI menu to toggle minimalist mode on/off. The setting is persisted immediately via onUpdate, and the preview reflects the change in real time via the RenderContext flag. When minimalist mode is active, the renderer forces rawValue: true on all widgets before calling render(), stripping decorative labels and prefixes globally. No per-widget changes needed. --- src/ccstatusline.ts | 3 +- src/tui/components/GlobalOverridesMenu.tsx | 19 ++ src/tui/components/StatusLinePreview.tsx | 3 +- .../__tests__/GlobalOverridesMenu.test.ts | 186 ++++++++++++++++++ src/types/RenderContext.ts | 1 + src/types/Settings.ts | 1 + src/utils/__tests__/renderer-ansi.test.ts | 31 +++ src/utils/renderer.ts | 3 +- 8 files changed, 244 insertions(+), 3 deletions(-) create mode 100644 src/tui/components/__tests__/GlobalOverridesMenu.test.ts diff --git a/src/ccstatusline.ts b/src/ccstatusline.ts index 6fe1c222..86c5b37c 100644 --- a/src/ccstatusline.ts +++ b/src/ccstatusline.ts @@ -149,7 +149,8 @@ async function renderMultipleLines(data: StatusJSON) { usageData, sessionDuration, skillsMetrics, - isPreview: false + isPreview: false, + minimalist: settings.minimalistMode ?? false }; // Always pre-render all widgets once (for efficiency) diff --git a/src/tui/components/GlobalOverridesMenu.tsx b/src/tui/components/GlobalOverridesMenu.tsx index ebc3a04d..f0dc94b8 100644 --- a/src/tui/components/GlobalOverridesMenu.tsx +++ b/src/tui/components/GlobalOverridesMenu.tsx @@ -29,6 +29,7 @@ export const GlobalOverridesMenu: React.FC = ({ settin const [separatorInput, setSeparatorInput] = useState(settings.defaultSeparator ?? ''); const [inheritColors, setInheritColors] = useState(settings.inheritSeparatorColors); const [globalBold, setGlobalBold] = useState(settings.globalBold); + const [minimalistMode, setMinimalistMode] = useState(settings.minimalistMode); const isPowerlineEnabled = settings.powerline.enabled; // Check if there are any manual separators in the current configuration @@ -133,6 +134,15 @@ export const GlobalOverridesMenu: React.FC = ({ settin globalBold: newGlobalBold }; onUpdate(updatedSettings); + } else if (input === 'm' || input === 'M') { + // Toggle minimalist mode + const newMinimalistMode = !minimalistMode; + setMinimalistMode(newMinimalistMode); + const updatedSettings = { + ...settings, + minimalistMode: newMinimalistMode + }; + onUpdate(updatedSettings); } else if (input === 'f' || input === 'F') { // Cycle through foreground colors const nextIndex = (currentFgIndex + 1) % fgColors.length; @@ -222,6 +232,12 @@ export const GlobalOverridesMenu: React.FC = ({ settin - Press (o) to toggle + + Minimalist Mode: + {minimalistMode ? '✓ Enabled' : '✗ Disabled'} + - Press (m) to toggle + + Default Padding: {settings.defaultPadding ? `"${settings.defaultPadding}"` : '(none)'} @@ -304,6 +320,9 @@ export const GlobalOverridesMenu: React.FC = ({ settin • Global Bold: Makes all text bold regardless of individual settings + + • Minimalist Mode: Strips decorative prefixes and labels from widgets + • Override colors: All widgets will use these colors instead of their configured colors diff --git a/src/tui/components/StatusLinePreview.tsx b/src/tui/components/StatusLinePreview.tsx index 9db79dad..2e595e90 100644 --- a/src/tui/components/StatusLinePreview.tsx +++ b/src/tui/components/StatusLinePreview.tsx @@ -37,6 +37,7 @@ const renderSingleLine = ( const context: RenderContext = { terminalWidth, isPreview: true, + minimalist: settings.minimalistMode ?? false, lineIndex, globalSeparatorIndex }; @@ -52,7 +53,7 @@ export const StatusLinePreview: React.FC = ({ lines, ter return { renderedLines: [], anyTruncated: false }; // Always pre-render all widgets once (for efficiency) - const preRenderedLines = preRenderAllWidgets(lines, settings, { terminalWidth, isPreview: true }); + const preRenderedLines = preRenderAllWidgets(lines, settings, { terminalWidth, isPreview: true, minimalist: settings.minimalistMode ?? false }); const preCalculatedMaxWidths = calculateMaxWidthsFromPreRendered(preRenderedLines, settings); let globalSeparatorIndex = 0; diff --git a/src/tui/components/__tests__/GlobalOverridesMenu.test.ts b/src/tui/components/__tests__/GlobalOverridesMenu.test.ts new file mode 100644 index 00000000..f822f9a1 --- /dev/null +++ b/src/tui/components/__tests__/GlobalOverridesMenu.test.ts @@ -0,0 +1,186 @@ +import { render } from 'ink'; +import { PassThrough } from 'node:stream'; +import React from 'react'; +import { + afterEach, + describe, + expect, + it, + vi +} from 'vitest'; + +import { DEFAULT_SETTINGS } from '../../../types/Settings'; +import { GlobalOverridesMenu } from '../GlobalOverridesMenu'; + +class MockTtyStream extends PassThrough { + isTTY = true; + columns = 120; + rows = 40; + + setRawMode() { + return this; + } + + ref() { + return this; + } + + unref() { + return this; + } +} + +interface CapturedWriteStream extends NodeJS.WriteStream { + clearOutput: () => void; + getOutput: () => string; +} + +function createMockStdin(): NodeJS.ReadStream { + return new MockTtyStream() as unknown as NodeJS.ReadStream; +} + +function createMockStdout(): CapturedWriteStream { + const stream = new MockTtyStream(); + const chunks: string[] = []; + + stream.on('data', (chunk: Buffer | string) => { + chunks.push(chunk.toString()); + }); + + return Object.assign(stream as unknown as NodeJS.WriteStream, { + clearOutput() { + chunks.length = 0; + }, + getOutput() { + return chunks.join(''); + } + }); +} + +function flushInk() { + return new Promise((resolve) => { + setTimeout(resolve, 25); + }); +} + +describe('GlobalOverridesMenu', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('displays minimalist mode as disabled by default', async () => { + const stdin = createMockStdin(); + const stdout = createMockStdout(); + const stderr = createMockStdout(); + const onUpdate = vi.fn(); + const onBack = vi.fn(); + + const instance = render( + React.createElement(GlobalOverridesMenu, { + settings: DEFAULT_SETTINGS, + onUpdate, + onBack + }), + { + stdin, + stdout, + stderr, + debug: true, + exitOnCtrlC: false, + patchConsole: false + } + ); + + try { + await flushInk(); + expect(stdout.getOutput()).toContain('Minimalist Mode:'); + expect(stdout.getOutput()).toContain('✗ Disabled'); + } finally { + instance.unmount(); + instance.cleanup(); + stdin.destroy(); + stdout.destroy(); + stderr.destroy(); + } + }); + + it('toggles minimalist mode on when (m) is pressed', async () => { + const stdin = createMockStdin(); + const stdout = createMockStdout(); + const stderr = createMockStdout(); + const onUpdate = vi.fn(); + const onBack = vi.fn(); + + const instance = render( + React.createElement(GlobalOverridesMenu, { + settings: { ...DEFAULT_SETTINGS, minimalistMode: false }, + onUpdate, + onBack + }), + { + stdin, + stdout, + stderr, + debug: true, + exitOnCtrlC: false, + patchConsole: false + } + ); + + try { + await flushInk(); + stdin.write('m'); + await flushInk(); + + expect(onUpdate).toHaveBeenCalledWith(expect.objectContaining({ + minimalistMode: true + })); + } finally { + instance.unmount(); + instance.cleanup(); + stdin.destroy(); + stdout.destroy(); + stderr.destroy(); + } + }); + + it('toggles minimalist mode off when (m) is pressed while enabled', async () => { + const stdin = createMockStdin(); + const stdout = createMockStdout(); + const stderr = createMockStdout(); + const onUpdate = vi.fn(); + const onBack = vi.fn(); + + const instance = render( + React.createElement(GlobalOverridesMenu, { + settings: { ...DEFAULT_SETTINGS, minimalistMode: true }, + onUpdate, + onBack + }), + { + stdin, + stdout, + stderr, + debug: true, + exitOnCtrlC: false, + patchConsole: false + } + ); + + try { + await flushInk(); + stdin.write('m'); + await flushInk(); + + expect(onUpdate).toHaveBeenCalledWith(expect.objectContaining({ + minimalistMode: false + })); + } finally { + instance.unmount(); + instance.cleanup(); + stdin.destroy(); + stdout.destroy(); + stderr.destroy(); + } + }); +}); diff --git a/src/types/RenderContext.ts b/src/types/RenderContext.ts index 9ba86aea..6cc69c18 100644 --- a/src/types/RenderContext.ts +++ b/src/types/RenderContext.ts @@ -30,6 +30,7 @@ export interface RenderContext { skillsMetrics?: SkillsMetrics | null; terminalWidth?: number | null; isPreview?: boolean; + minimalist?: boolean; lineIndex?: number; // Index of the current line being rendered (for theme cycling) globalSeparatorIndex?: number; // Global separator index that continues across lines } \ No newline at end of file diff --git a/src/types/Settings.ts b/src/types/Settings.ts index ebde1fd7..817dd732 100644 --- a/src/types/Settings.ts +++ b/src/types/Settings.ts @@ -49,6 +49,7 @@ export const SettingsSchema = z.object({ overrideBackgroundColor: z.string().optional(), overrideForegroundColor: z.string().optional(), globalBold: z.boolean().default(false), + minimalistMode: z.boolean().default(false), powerline: PowerlineConfigSchema.default({ enabled: false, separators: ['\uE0B0'], diff --git a/src/utils/__tests__/renderer-ansi.test.ts b/src/utils/__tests__/renderer-ansi.test.ts index ad0d1fcd..45db2729 100644 --- a/src/utils/__tests__/renderer-ansi.test.ts +++ b/src/utils/__tests__/renderer-ansi.test.ts @@ -190,4 +190,35 @@ describe('renderer ANSI/OSC handling', () => { expect(truncated).toContain(OSC8_CLOSE); expect(getVisibleWidth(truncated)).toBeLessThanOrEqual(8); }); +}); + +describe('renderer minimalist mode', () => { + it('renders widget as raw value when minimalist mode is enabled', () => { + const widgets: WidgetItem[] = [{ id: 'model1', type: 'model' }]; + const settings = createSettings(); + const context: RenderContext = { + isPreview: true, + minimalist: true + }; + + const preRenderedLines = preRenderAllWidgets([widgets], settings, context); + const content = preRenderedLines[0]?.[0]?.content; + + // With minimalist mode, model widget should render raw value ('Claude') not 'Model: Claude' + expect(content).toBe('Claude'); + }); + + it('renders widget with label when minimalist mode is disabled', () => { + const widgets: WidgetItem[] = [{ id: 'model1', type: 'model' }]; + const settings = createSettings(); + const context: RenderContext = { + isPreview: true, + minimalist: false + }; + + const preRenderedLines = preRenderAllWidgets([widgets], settings, context); + const content = preRenderedLines[0]?.[0]?.content; + + expect(content).toBe('Model: Claude'); + }); }); \ No newline at end of file diff --git a/src/utils/renderer.ts b/src/utils/renderer.ts index 546f30d8..5f7d7acc 100644 --- a/src/utils/renderer.ts +++ b/src/utils/renderer.ts @@ -513,7 +513,8 @@ export function preRenderAllWidgets( continue; } - const widgetText = widgetImpl.render(widget, context, settings) ?? ''; + const effectiveWidget = context.minimalist ? { ...widget, rawValue: true } : widget; + const widgetText = widgetImpl.render(effectiveWidget, context, settings) ?? ''; // Store the rendered content without padding (padding is applied later) // Use stringWidth to properly calculate Unicode character display width