From 4f7677054344e84cb7ccffcee493cc3e5a21564e Mon Sep 17 00:00:00 2001 From: Eric Engberg Date: Fri, 16 Oct 2026 18:28:32 -0500 Subject: [PATCH 1/5] fix(powerline): keep a chosen Custom theme when Powerline is turned back on Turning Powerline off and on again in Powerline Setup replaced a Custom theme with Nord Aurora. Nord Aurora's colors then drew over every widget's own colors, in the preview and in the status line, until the user went back to Themes and picked Custom again. Turning Powerline on gave the default theme to an unset theme and to 'custom' alike, treating both as the first time Powerline is enabled. An unset theme covers that case, since the settings default is no theme. 'custom' is only set when the user picks Custom, or copies a theme's colors onto the widgets with (c)ustomize, so it is now kept. --- .../__tests__/PowerlineSetup.test.ts | 65 +++++++++++++++++++ .../__tests__/powerline-settings.test.ts | 15 +++++ src/utils/powerline-settings.ts | 4 +- 3 files changed, 83 insertions(+), 1 deletion(-) diff --git a/src/tui/components/__tests__/PowerlineSetup.test.ts b/src/tui/components/__tests__/PowerlineSetup.test.ts index ce9dfbf20..47bed0f90 100644 --- a/src/tui/components/__tests__/PowerlineSetup.test.ts +++ b/src/tui/components/__tests__/PowerlineSetup.test.ts @@ -181,6 +181,71 @@ describe('PowerlineSetup helpers', () => { } }); + it('keeps a chosen Custom theme when (t) turns Powerline off and on again', async () => { + const stdin = createMockStdin(); + const stdout = createMockStdout(); + const stderr = createMockStdout(); + // Each update is drawn again with the new settings, as the app does + const onUpdate = vi.fn((settings) => { + instance.rerender(React.createElement(PowerlineSetup, { ...props, settings })); + }); + const props: PowerlineSetupProps = { + settings: { + ...DEFAULT_SETTINGS, + lines: [[{ id: '1', type: 'model', color: 'red', backgroundColor: 'bgYellow' }]], + powerline: { + ...DEFAULT_SETTINGS.powerline, + enabled: true, + theme: 'custom' + } + }, + powerlineFontStatus: { installed: true }, + onUpdate, + onBack: vi.fn(), + onInstallFonts: vi.fn(), + installingFonts: false, + fontInstallMessage: null, + onClearMessage: vi.fn() + }; + const instance = render( + React.createElement(PowerlineSetup, props), + { + stdin, + stdout, + stderr, + debug: true, + exitOnCtrlC: false, + patchConsole: false + } + ); + + try { + await waitFor(() => { + expect(stdout.getOutput()).toContain('✓ Enabled'); + }); + + stdin.write('t'); + await waitFor(() => { + expect(stdout.getOutput()).toContain('✗ Disabled'); + }); + + stdin.write('t'); + await waitFor(() => { + expect(onUpdate).toHaveBeenCalledTimes(2); + }); + + const updatedSettings = onUpdate.mock.calls[1]?.[0]; + expect(updatedSettings?.powerline.enabled).toBe(true); + expect(updatedSettings?.powerline.theme).toBe('custom'); + } finally { + instance.unmount(); + instance.cleanup(); + stdin.destroy(); + stdout.destroy(); + stderr.destroy(); + } + }); + it('warns when a global foreground override is active', async () => { const stdin = createMockStdin(); const stdout = createMockStdout(); diff --git a/src/utils/__tests__/powerline-settings.test.ts b/src/utils/__tests__/powerline-settings.test.ts index 4ab63b0ed..696951975 100644 --- a/src/utils/__tests__/powerline-settings.test.ts +++ b/src/utils/__tests__/powerline-settings.test.ts @@ -40,6 +40,21 @@ describe('powerline settings helpers', () => { expect(updated.powerline.theme).toBe('catppuccin'); }); + // Custom is only ever picked on purpose, and the widget colors draw only under it + it('keeps a chosen Custom theme when enabling powerline', () => { + const settings = { + ...DEFAULT_SETTINGS, + powerline: { + ...DEFAULT_SETTINGS.powerline, + enabled: false, + theme: 'custom' + } + }; + + const updated = buildEnabledPowerlineSettings(settings, false); + expect(updated.powerline.theme).toBe('custom'); + }); + it('removes manual separators while preserving flex separators when requested', () => { const line: WidgetItem[] = [ { id: '1', type: 'model' }, diff --git a/src/utils/powerline-settings.ts b/src/utils/powerline-settings.ts index 150197b16..d33413223 100644 --- a/src/utils/powerline-settings.ts +++ b/src/utils/powerline-settings.ts @@ -2,8 +2,10 @@ import type { Settings } from '../types/Settings'; import { getDefaultPowerlineTheme } from './colors'; +// Only a theme never picked gets the default. Custom is picked on purpose, and +// the widgets' own colors draw only under it function resolveEnabledPowerlineTheme(theme: string | undefined): string { - if (!theme || theme === 'custom') { + if (!theme) { return getDefaultPowerlineTheme(); } From 2dad92b9bc413cf1762642bd9f76cf53c5bfaa32 Mon Sep 17 00:00:00 2001 From: Eric Engberg Date: Fri, 16 Oct 2026 18:31:25 -0500 Subject: [PATCH 2/5] fix(powerline): put back the default padding when Powerline is turned off Turning Powerline on sets Default Padding to one space, so segment text doesn't touch the arrows. Turning it off only cleared the enabled flag, so the plain line kept that space for good: a line drawn as "Model | Ctx" came back as " Model | Ctx " after trying Powerline, and a padding the user had set, such as none or two spaces, was lost. That includes a fresh install, where Default Padding is unset. Turning Powerline on now remembers the padding it replaces in the Powerline settings, and turning it off puts that padding back and forgets it. When the padding was changed while Powerline was on, the change is the user's own and is kept. An unset padding is remembered as none, which renders the same. Settings saved before this change have nothing remembered, so turning Powerline off keeps the padding, as before. --- docs/USAGE.md | 1 + src/tui/components/PowerlineSetup.tsx | 13 +- .../__tests__/PowerlineSetup.test.ts | 127 ++++++++++++++++++ src/types/PowerlineConfig.ts | 2 + .../__tests__/powerline-settings.test.ts | 15 +++ src/utils/powerline-settings.ts | 27 +++- 6 files changed, 175 insertions(+), 10 deletions(-) diff --git a/docs/USAGE.md b/docs/USAGE.md index a60352bee..4df9c86fc 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -107,6 +107,7 @@ Configure global formatting preferences that apply to all widgets: - **Default Padding** - Add consistent padding around each widget - **Padding Side** - Choose whether default padding applies to **Both** sides (default), **Left only**, or **Right only** +- Turning Powerline on sets Default Padding to one space. Turning Powerline off puts back the padding from before, unless you changed it while Powerline was on. - **Default Separator** - Automatically insert a separator between all widgets - Press **(p)** to edit padding - Press **(d)** to cycle padding side diff --git a/src/tui/components/PowerlineSetup.tsx b/src/tui/components/PowerlineSetup.tsx index 7b5aad27b..c33face8f 100644 --- a/src/tui/components/PowerlineSetup.tsx +++ b/src/tui/components/PowerlineSetup.tsx @@ -10,7 +10,10 @@ import type { PowerlineConfig } from '../../types/PowerlineConfig'; import type { Settings } from '../../types/Settings'; import { getPlainInput } from '../../utils/input-guards'; import { type PowerlineFontStatus } from '../../utils/powerline'; -import { buildEnabledPowerlineSettings } from '../../utils/powerline-settings'; +import { + buildDisabledPowerlineSettings, + buildEnabledPowerlineSettings +} from '../../utils/powerline-settings'; import { ConfirmDialog } from './ConfirmDialog'; import { @@ -197,13 +200,7 @@ export const PowerlineSetup: React.FC = ({ onUpdate(buildEnabledPowerlineSettings(settings, false)); } } else { - onUpdate({ - ...settings, - powerline: { - ...powerlineConfig, - enabled: false - } - }); + onUpdate(buildDisabledPowerlineSettings(settings)); } } else if (shortcut === 'i' || shortcut === 'I') { setConfirmingFontInstall(true); diff --git a/src/tui/components/__tests__/PowerlineSetup.test.ts b/src/tui/components/__tests__/PowerlineSetup.test.ts index 47bed0f90..4608f2f0d 100644 --- a/src/tui/components/__tests__/PowerlineSetup.test.ts +++ b/src/tui/components/__tests__/PowerlineSetup.test.ts @@ -246,6 +246,133 @@ describe('PowerlineSetup helpers', () => { } }); + it.each([ + ['unset', undefined, ''], + ['none', '', ''], + ['two spaces', ' ', ' '] + ])('restores the default padding (%s) when (t) turns Powerline on and off again', async (_name, padding, restored) => { + const stdin = createMockStdin(); + const stdout = createMockStdout(); + const stderr = createMockStdout(); + // Each update is drawn again with the new settings, as the app does + const onUpdate = vi.fn((settings) => { + instance.rerender(React.createElement(PowerlineSetup, { ...props, settings })); + }); + const props: PowerlineSetupProps = { + settings: { + ...DEFAULT_SETTINGS, + lines: [[{ id: '1', type: 'model' }, { id: '2', type: 'context-length' }]], + defaultSeparator: '|', + defaultPadding: padding + }, + powerlineFontStatus: { installed: true }, + onUpdate, + onBack: vi.fn(), + onInstallFonts: vi.fn(), + installingFonts: false, + fontInstallMessage: null, + onClearMessage: vi.fn() + }; + const instance = render( + React.createElement(PowerlineSetup, props), + { + stdin, + stdout, + stderr, + debug: true, + exitOnCtrlC: false, + patchConsole: false + } + ); + + try { + await waitFor(() => { + expect(stdout.getOutput()).toContain('✗ Disabled'); + }); + + stdin.write('t'); + await waitFor(() => { + expect(stdout.getOutput()).toContain('✓ Enabled'); + }); + expect(onUpdate.mock.calls[0]?.[0].defaultPadding).toBe(' '); + + stdin.write('t'); + await waitFor(() => { + expect(onUpdate).toHaveBeenCalledTimes(2); + }); + + const updatedSettings = onUpdate.mock.calls[1]?.[0]; + expect(updatedSettings?.powerline.enabled).toBe(false); + expect(updatedSettings?.defaultPadding).toBe(restored); + expect(updatedSettings?.powerline.paddingToRestore).toBeUndefined(); + } finally { + instance.unmount(); + instance.cleanup(); + stdin.destroy(); + stdout.destroy(); + stderr.destroy(); + } + }); + + // A padding set while Powerline was on is the user's own choice + it('keeps a default padding changed since Powerline was turned on when (t) turns it off', async () => { + const stdin = createMockStdin(); + const stdout = createMockStdout(); + const stderr = createMockStdout(); + const onUpdate = vi.fn(); + const instance = render( + React.createElement(PowerlineSetup, { + settings: { + ...DEFAULT_SETTINGS, + defaultPadding: ' ', + powerline: { + ...DEFAULT_SETTINGS.powerline, + enabled: true, + theme: 'nord-aurora', + paddingToRestore: '' + } + }, + powerlineFontStatus: { installed: true }, + onUpdate, + onBack: vi.fn(), + onInstallFonts: vi.fn(), + installingFonts: false, + fontInstallMessage: null, + onClearMessage: vi.fn() + }), + { + stdin, + stdout, + stderr, + debug: true, + exitOnCtrlC: false, + patchConsole: false + } + ); + + try { + await waitFor(() => { + expect(stdout.getOutput()).toContain('✓ Enabled'); + }); + + stdin.write('t'); + await waitFor(() => { + expect(onUpdate).toHaveBeenCalled(); + }); + + const updatedSettings = onUpdate.mock.calls[0]?.[0]; + expect(updatedSettings?.powerline.enabled).toBe(false); + expect(updatedSettings?.defaultPadding).toBe(' '); + expect(updatedSettings?.powerline.paddingToRestore).toBeUndefined(); + } finally { + instance.unmount(); + instance.cleanup(); + stdin.destroy(); + stdout.destroy(); + stderr.destroy(); + } + }); + it('warns when a global foreground override is active', async () => { const stdin = createMockStdin(); const stdout = createMockStdout(); diff --git a/src/types/PowerlineConfig.ts b/src/types/PowerlineConfig.ts index 8657a0464..903ee0c64 100644 --- a/src/types/PowerlineConfig.ts +++ b/src/types/PowerlineConfig.ts @@ -8,6 +8,8 @@ export const PowerlineConfigSchema = z.object({ startCaps: z.array(z.string()).default([]), endCaps: z.array(z.string()).default([]), theme: z.string().optional(), + // The Default Padding that turning Powerline on replaced, put back when it's turned off + paddingToRestore: z.string().optional(), autoAlign: z.boolean().default(false), continueThemeAcrossLines: z.boolean().default(false) }); diff --git a/src/utils/__tests__/powerline-settings.test.ts b/src/utils/__tests__/powerline-settings.test.ts index 696951975..a1f94eec6 100644 --- a/src/utils/__tests__/powerline-settings.test.ts +++ b/src/utils/__tests__/powerline-settings.test.ts @@ -55,6 +55,21 @@ describe('powerline settings helpers', () => { expect(updated.powerline.theme).toBe('custom'); }); + it.each([ + ['unset', undefined, ''], + ['none', '', ''], + ['two spaces', ' ', ' '] + ])('remembers the default padding it replaces (%s) when enabling powerline', (_name, padding, remembered) => { + const settings = { + ...DEFAULT_SETTINGS, + defaultPadding: padding + }; + + const updated = buildEnabledPowerlineSettings(settings, false); + expect(updated.defaultPadding).toBe(' '); + expect(updated.powerline.paddingToRestore).toBe(remembered); + }); + it('removes manual separators while preserving flex separators when requested', () => { const line: WidgetItem[] = [ { id: '1', type: 'model' }, diff --git a/src/utils/powerline-settings.ts b/src/utils/powerline-settings.ts index d33413223..cae744cad 100644 --- a/src/utils/powerline-settings.ts +++ b/src/utils/powerline-settings.ts @@ -2,6 +2,9 @@ import type { Settings } from '../types/Settings'; import { getDefaultPowerlineTheme } from './colors'; +// The Default Padding turning Powerline on sets, so segment text doesn't touch the arrows +const POWERLINE_PADDING = ' '; + // Only a theme never picked gets the default. Custom is picked on purpose, and // the widgets' own colors draw only under it function resolveEnabledPowerlineTheme(theme: string | undefined): string { @@ -26,9 +29,29 @@ export function buildEnabledPowerlineSettings(settings: Settings, removeManualSe theme: resolveEnabledPowerlineTheme(powerlineConfig.theme), // Separators are initialized by schema defaults, preserve existing values. separators: powerlineConfig.separators, - separatorInvertBackground: powerlineConfig.separatorInvertBackground + separatorInvertBackground: powerlineConfig.separatorInvertBackground, + // Unset and empty padding render the same + paddingToRestore: settings.defaultPadding ?? '' }, - defaultPadding: ' ', + defaultPadding: POWERLINE_PADDING, lines }; } + +/** + * Turns Powerline off. The Default Padding that turning it on replaced comes + * back, unless the padding was changed since: that change is the user's own. + */ +export function buildDisabledPowerlineSettings(settings: Settings): Settings { + const { paddingToRestore, ...powerlineConfig } = settings.powerline; + const restoresPadding = paddingToRestore !== undefined && settings.defaultPadding === POWERLINE_PADDING; + + return { + ...settings, + powerline: { + ...powerlineConfig, + enabled: false + }, + defaultPadding: restoresPadding ? paddingToRestore : settings.defaultPadding + }; +} From 96903f9a3e73435988df91b17067412b64be212c Mon Sep 17 00:00:00 2001 From: Eric Engberg Date: Fri, 16 Oct 2026 18:33:27 -0500 Subject: [PATCH 3/5] fix(tui): color the font install message by the install's result On Windows, a Powerline font install that worked showed "Installed N Powerline fonts. Please restart your terminal..." in red, the error color. Powerline Setup picked green or red by looking for "success" in the message. The macOS and Linux message says "installed successfully", but the Windows one doesn't. The app passed on only the message and dropped the install's success flag, so the screen had nothing else to go on. The app now keeps the whole install result, and Powerline Setup colors the message by its success flag. --- src/tui/App.tsx | 5 +- src/tui/components/PowerlineSetup.tsx | 17 ++++-- .../__tests__/PowerlineSetup.test.ts | 59 +++++++++++++++++++ src/utils/powerline.ts | 7 ++- 4 files changed, 81 insertions(+), 7 deletions(-) diff --git a/src/tui/App.tsx b/src/tui/App.tsx index 23e5c51a4..a4b3a14c0 100644 --- a/src/tui/App.tsx +++ b/src/tui/App.tsx @@ -63,6 +63,7 @@ import { checkPowerlineFonts, checkPowerlineFontsAsync, installPowerlineFonts, + type FontInstallResult, type PowerlineFontStatus } from '../utils/powerline'; import { getPackageVersion } from '../utils/terminal'; @@ -491,7 +492,7 @@ export const App: React.FC = () => { const [terminalWidth, setTerminalWidth] = useState(process.stdout.columns || 80); const [powerlineFontStatus, setPowerlineFontStatus] = useState({ installed: false }); const [installingFonts, setInstallingFonts] = useState(false); - const [fontInstallMessage, setFontInstallMessage] = useState(null); + const [fontInstallMessage, setFontInstallMessage] = useState(null); const [existingStatusLine, setExistingStatusLine] = useState(null); const [flashMessage, setFlashMessage] = useState(null); const [previewIsTruncated, setPreviewIsTruncated] = useState(false); @@ -1493,7 +1494,7 @@ export const App: React.FC = () => { setTimeout(() => { void installPowerlineFonts().then((result) => { setInstallingFonts(false); - setFontInstallMessage(result.message); + setFontInstallMessage(result); // Refresh font status void checkPowerlineFontsAsync().then((asyncStatus) => { setPowerlineFontStatus(asyncStatus); diff --git a/src/tui/components/PowerlineSetup.tsx b/src/tui/components/PowerlineSetup.tsx index c33face8f..bd3772c88 100644 --- a/src/tui/components/PowerlineSetup.tsx +++ b/src/tui/components/PowerlineSetup.tsx @@ -9,7 +9,10 @@ import React, { useState } from 'react'; import type { PowerlineConfig } from '../../types/PowerlineConfig'; import type { Settings } from '../../types/Settings'; import { getPlainInput } from '../../utils/input-guards'; -import { type PowerlineFontStatus } from '../../utils/powerline'; +import { + type FontInstallResult, + type PowerlineFontStatus +} from '../../utils/powerline'; import { buildDisabledPowerlineSettings, buildEnabledPowerlineSettings @@ -106,6 +109,12 @@ export function getThemeDisplay(powerlineConfig: PowerlineConfig): string { return theme.charAt(0).toUpperCase() + theme.slice(1); } +// From the install's own result, not its wording: the Windows success message +// doesn't say "success" +export function getFontInstallMessageColor(result: FontInstallResult): 'green' | 'red' { + return result.success ? 'green' : 'red'; +} + export function buildPowerlineSetupMenuItems( powerlineConfig: PowerlineConfig ): ListEntry[] { @@ -150,7 +159,7 @@ export interface PowerlineSetupProps { onBack: () => void; onInstallFonts: () => void; installingFonts: boolean; - fontInstallMessage: string | null; + fontInstallMessage: FontInstallResult | null; onClearMessage: () => void; } @@ -378,8 +387,8 @@ export const PowerlineSetup: React.FC = ({ ) : fontInstallMessage ? ( - - {fontInstallMessage} + + {fontInstallMessage.message} Press any key to continue... diff --git a/src/tui/components/__tests__/PowerlineSetup.test.ts b/src/tui/components/__tests__/PowerlineSetup.test.ts index 4608f2f0d..72339bd57 100644 --- a/src/tui/components/__tests__/PowerlineSetup.test.ts +++ b/src/tui/components/__tests__/PowerlineSetup.test.ts @@ -22,6 +22,7 @@ import { PowerlineSetup, buildPowerlineSetupMenuItems, getCapDisplay, + getFontInstallMessageColor, getSeparatorDisplay, getThemeDisplay, type PowerlineSetupProps @@ -87,6 +88,22 @@ describe('PowerlineSetup helpers', () => { expect(getThemeDisplay(config)).toBe('Gruvbox'); }); + // The Windows success message doesn't say "success" + it('colors a font install message by whether the install worked', () => { + expect(getFontInstallMessageColor({ + success: true, + message: 'Installed 3 Powerline fonts. Please restart your terminal and select a Powerline font from your terminal settings.' + })).toBe('green'); + expect(getFontInstallMessageColor({ + success: true, + message: 'Powerline fonts installed successfully! Please restart your terminal and select a Powerline font' + })).toBe('green'); + expect(getFontInstallMessageColor({ + success: false, + message: 'Failed to install Powerline fonts: No fonts were installed. You can manually install fonts from: https://github.com/powerline/fonts' + })).toBe('red'); + }); + it('builds powerline setup items with disabled states and sublabels', () => { const disabledItems = buildPowerlineSetupMenuItems({ ...DEFAULT_SETTINGS.powerline, @@ -373,6 +390,48 @@ describe('PowerlineSetup helpers', () => { } }); + it('shows the font install result it is given', async () => { + const stdin = createMockStdin(); + const stdout = createMockStdout(); + const stderr = createMockStdout(); + const instance = render( + React.createElement(PowerlineSetup, { + settings: DEFAULT_SETTINGS, + powerlineFontStatus: { installed: true }, + onUpdate: vi.fn(), + onBack: vi.fn(), + onInstallFonts: vi.fn(), + installingFonts: false, + fontInstallMessage: { + success: true, + message: 'Installed 3 Powerline fonts. Please restart your terminal and select a Powerline font from your terminal settings.' + }, + onClearMessage: vi.fn() + }), + { + stdin, + stdout, + stderr, + debug: true, + exitOnCtrlC: false, + patchConsole: false + } + ); + + try { + await waitFor(() => { + expect(stdout.getOutput()).toContain('Installed 3 Powerline fonts.'); + expect(stdout.getOutput()).toContain('Press any key to continue...'); + }); + } finally { + instance.unmount(); + instance.cleanup(); + stdin.destroy(); + stdout.destroy(); + stderr.destroy(); + } + }); + it('warns when a global foreground override is active', async () => { const stdin = createMockStdin(); const stdout = createMockStdout(); diff --git a/src/utils/powerline.ts b/src/utils/powerline.ts index aa2bce7e4..0271a7be1 100644 --- a/src/utils/powerline.ts +++ b/src/utils/powerline.ts @@ -156,10 +156,15 @@ export async function checkPowerlineFontsAsync(): Promise { } } +export interface FontInstallResult { + success: boolean; + message: string; +} + /** * Install Powerline fonts on the system */ -export async function installPowerlineFonts(): Promise<{ success: boolean; message: string }> { +export async function installPowerlineFonts(): Promise { // Ensure this is always async await Promise.resolve(); From d1aeff40f77f3cd7766a7be67dc9c58fb9ef108d Mon Sep 17 00:00:00 2001 From: Eric Engberg Date: Fri, 16 Oct 2026 18:34:56 -0500 Subject: [PATCH 4/5] fix(powerline): report why a font install failed instead of asking for Git MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Installing fonts from Powerline Setup while offline, behind a proxy or behind a firewall said "Git is required to install Powerline fonts. Please install Git and try again." with git installed. The real error and the link to install the fonts by hand were hidden. The installer took any error whose text contained "git" to mean git is missing. execFileSync's error for a failed clone always starts with "Command failed: git clone ...", so every clone failure matched, whatever its cause. Only an ENOENT from starting git means git isn't installed, on Node and on Bun. That case keeps its message. Every other failure now shows the existing "Failed to install Powerline fonts: ... You can manually install fonts from: https://github.com/powerline/fonts" message with one line that says why, rather than the whole command line and its output. That is git's first "fatal:" or "error:" line, such as "fatal: unable to access 'https://github.com/powerline/fonts.git/': Could not resolve host: github.com", or else the first line of stderr that doesn't name the temp folder git clones into. That skips git's "Cloning into ''..." progress line in any language. git translates it and its "fatal:" prefix ("正克隆到 ''..." and "致命错误:" in Chinese, "Clonage dans ''..." and "fatal : " in French), but every translation quotes the folder. On a Mac without the Command Line Tools, where /usr/bin/git is a stub, the line shown is the stub's "xcode-select: note: No developer tools were found, requesting install." A command stopped by a signal says so, as in "git was stopped by SIGKILL". The existing cleanup test now fails the clone the way a missing git does. --- src/utils/__tests__/powerline-install.test.ts | 116 +++++++++++++++++- src/utils/powerline.ts | 55 ++++++++- 2 files changed, 164 insertions(+), 7 deletions(-) diff --git a/src/utils/__tests__/powerline-install.test.ts b/src/utils/__tests__/powerline-install.test.ts index ba041dad3..994bc0100 100644 --- a/src/utils/__tests__/powerline-install.test.ts +++ b/src/utils/__tests__/powerline-install.test.ts @@ -75,7 +75,8 @@ describe('installPowerlineFonts', () => { const cloneDirs: string[] = []; mockExecFileSync.mockImplementation((file, args = []) => { cloneDirs.push(args[args.length - 1] ?? ''); - throw new Error(`${file} failed`); + // What execFileSync throws when git isn't installed + throw Object.assign(new Error(`spawnSync ${file} ENOENT`), { code: 'ENOENT', syscall: `spawnSync ${file}`, path: file }); }); const result = await installPowerlineFonts(); @@ -87,4 +88,117 @@ describe('installPowerlineFonts', () => { expect(cloneDirs).toHaveLength(1); expect(fs.existsSync(cloneDirs[0] ?? '')).toBe(false); }); + + // A clone's error always starts "Command failed: git clone ...", whatever went wrong + it.each([ + ['offline', 'Could not resolve host: github.com'], + ['behind an unreachable proxy', 'Failed to connect to 127.0.0.1 port 9 after 0 ms: Couldn\'t connect to server'] + ])('reports why the clone failed and links the manual install when %s', async (_name, reason) => { + const fatal = `fatal: unable to access '${FONTS_REPO}/': ${reason}`; + mockExecFileSync.mockImplementation((file, args = []) => { + const dir = args[args.length - 1] ?? ''; + const stderr = `Cloning into '${dir}'...\n${fatal}\n`; + // What execFileSync throws when the command exits with an error + throw Object.assign(new Error(`Command failed: ${file} ${args.join(' ')}\n${stderr}`), { status: 128, stderr }); + }); + + const result = await installPowerlineFonts(); + + expect(result).toEqual({ + success: false, + message: `Failed to install Powerline fonts: ${fatal}. You can manually install fonts from: https://github.com/powerline/fonts` + }); + }); + + // The error repeats all of stderr, and the line that says why isn't always the last or in English + it.each([ + [ + 'git is the macOS stub for missing Command Line Tools', + (_dir: string) => [ + 'xcode-select: note: No developer tools were found, requesting install.', + 'If developer tools are located at a non-default location on disk, use `sudo xcode-select --switch path/to/Xcode.app` to specify the Xcode that you wish to use for command line developer tools, and cancel the installation dialog.', + 'See `man xcode-select` for more details.' + ], + { status: 1, signal: null }, + 'xcode-select: note: No developer tools were found, requesting install.' + ], + [ + 'git is killed after it starts the clone', + (dir: string) => [`Cloning into '${dir}'...`], + { status: null, signal: 'SIGKILL' }, + 'git was stopped by SIGKILL.' + ], + [ + 'git gives up after a dropped connection', + (dir: string) => [ + `Cloning into '${dir}'...`, + 'error: RPC failed; curl 92 HTTP/2 stream 5 was not closed cleanly: CANCEL (err 8)', + 'error: 2771 bytes of body are still expected', + 'fetch-pack: unexpected disconnect while reading sideband packet', + 'fatal: early EOF', + 'fatal: fetch-pack: invalid index-pack output' + ], + { status: 128, signal: null }, + 'error: RPC failed; curl 92 HTTP/2 stream 5 was not closed cleanly: CANCEL (err 8).' + ], + [ + 'git speaks Chinese', + (dir: string) => [ + `正克隆到 '${dir}'...`, + `致命错误:无法访问 '${FONTS_REPO}/':Could not resolve host: github.com` + ], + { status: 128, signal: null }, + `致命错误:无法访问 '${FONTS_REPO}/':Could not resolve host: github.com.` + ], + [ + 'git speaks French', + (dir: string) => [ + `Clonage dans '${dir}'...`, + `fatal : impossible d'accéder à '${FONTS_REPO}/' : Could not resolve host: github.com` + ], + { status: 128, signal: null }, + `fatal : impossible d'accéder à '${FONTS_REPO}/' : Could not resolve host: github.com.` + ], + [ + 'the server says why before git does', + (dir: string) => [ + `Clonage dans '${dir}'...`, + 'remote: Repository not found.', + `fatal : dépôt '${FONTS_REPO}/' non trouvé` + ], + { status: 128, signal: null }, + 'remote: Repository not found.' + ] + ])('reports why the clone failed when %s', async (_name, stderrLines, exit, reason) => { + mockExecFileSync.mockImplementation((file, args = []) => { + const stderr = `${stderrLines(args[args.length - 1] ?? '').join('\n')}\n`; + // What execFileSync throws when the command exits with an error or is killed + throw Object.assign(new Error(`Command failed: ${file} ${args.join(' ')}\n${stderr}`), { ...exit, stderr }); + }); + + const result = await installPowerlineFonts(); + + expect(result).toEqual({ + success: false, + message: `Failed to install Powerline fonts: ${reason} You can manually install fonts from: https://github.com/powerline/fonts` + }); + }); + + it.skipIf(process.platform === 'win32')('names install.sh when it is killed', async () => { + mockExecFileSync.mockImplementation((file, args = []) => { + if (file === 'git') { + fs.writeFileSync(path.join(args[args.length - 1] ?? '', 'install.sh'), '#!/bin/sh\n'); + return ''; + } + // What execFileSync throws when the command is killed before writing to stderr + throw Object.assign(new Error(`Command failed: ${file}`), { status: null, signal: 'SIGTERM', stderr: '' }); + }); + + const result = await installPowerlineFonts(); + + expect(result).toEqual({ + success: false, + message: 'Failed to install Powerline fonts: install.sh was stopped by SIGTERM. You can manually install fonts from: https://github.com/powerline/fonts' + }); + }); }); diff --git a/src/utils/powerline.ts b/src/utils/powerline.ts index 0271a7be1..82baf1748 100644 --- a/src/utils/powerline.ts +++ b/src/utils/powerline.ts @@ -161,6 +161,46 @@ export interface FontInstallResult { message: string; } +// Only git failing to start means it isn't installed. A clone that fails +// offline or behind a proxy names git in its error too +function isGitMissing(error: unknown): boolean { + if (typeof error !== 'object' || error === null) { + return false; + } + const { code, path: command } = error as NodeJS.ErrnoException; + return code === 'ENOENT' && command === 'git'; +} + +// git's own error lines in English, such as "fatal: unable to access ...: +// Could not resolve host" +const GIT_ERROR_LINE = /^(?:fatal|error):/; + +// Why the install failed, as a sentence. A failed command's error repeats its +// whole command line and output, so this is the signal that stopped it, or +// one line of its stderr: git's first error line, or else its first line +// that doesn't name the temp folder, such as the macOS git stub's "No +// developer tools were found" when the Command Line Tools aren't installed. +// That skips git's progress line, "Cloning into ''...", which +// git translates, as it does its error prefixes +function describeInstallError(error: unknown, command: string, tempDir: string | undefined): string { + const failure = typeof error === 'object' && error !== null + ? error as { signal?: unknown; stderr?: unknown } + : undefined; + let reason = error instanceof Error ? error.message : String(error); + if (typeof failure?.signal === 'string') { + reason = `${command} was stopped by ${failure.signal}`; + } else if (typeof failure?.stderr === 'string') { + const lines = failure.stderr.split('\n') + .map(line => line.trim()) + .filter(line => line !== ''); + reason = lines.find(line => GIT_ERROR_LINE.test(line)) + ?? lines.find(line => tempDir === undefined || !line.includes(tempDir)) + ?? reason; + } + + return /[.!?]$/.test(reason) ? reason : `${reason}.`; +} + /** * Install Powerline fonts on the system */ @@ -168,6 +208,11 @@ export async function installPowerlineFonts(): Promise { // Ensure this is always async await Promise.resolve(); + // The command running now, named if a signal stops it + let command = 'git'; + // The folder the fonts are cloned into, named by git's progress line + let tempDir: string | undefined; + try { const platform = os.platform(); let fontDir: string; @@ -201,7 +246,7 @@ export async function installPowerlineFonts(): Promise { // A new folder only this account can use for the download: os.tmpdir() // is shared on Linux, and a folder another account created first would // let it swap install.sh between the clone and running it - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-powerline-fonts-')); + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-powerline-fonts-')); try { // Clone Powerline fonts repository @@ -224,6 +269,7 @@ export async function installPowerlineFonts(): Promise { fs.chmodSync(installScript, 0o755); // Run install script + command = 'install.sh'; execFileSync(installScript, [], { cwd: tempDir, stdio: 'pipe', @@ -319,10 +365,7 @@ export async function installPowerlineFonts(): Promise { } } } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - - // Provide helpful error messages - if (errorMessage.includes('git')) { + if (isGitMissing(error)) { return { success: false, message: 'Git is required to install Powerline fonts. Please install Git and try again.' @@ -331,7 +374,7 @@ export async function installPowerlineFonts(): Promise { return { success: false, - message: `Failed to install Powerline fonts: ${errorMessage}. You can manually install fonts from: https://github.com/powerline/fonts` + message: `Failed to install Powerline fonts: ${describeInstallError(error, command, tempDir)} You can manually install fonts from: https://github.com/powerline/fonts` }; } } From b05215553b6991ef945e0fd38622094e7cd7ad02 Mon Sep 17 00:00:00 2001 From: Eric Engberg Date: Fri, 30 Oct 2026 01:09:59 -0500 Subject: [PATCH 5/5] test(powerline): keep separators, a Custom theme and the padding across on and off Turning Powerline on now leaves manual separators in the lines, and this branch keeps a chosen Custom theme and puts back the padding it replaced when Powerline goes off. Each was tested alone. A round trip through both helpers, and through (t) in Powerline Setup with the confirmation that manual separators bring up, checks all three come back together. --- .../__tests__/PowerlineSetup.test.ts | 78 +++++++++++++++++++ .../__tests__/powerline-settings.test.ts | 33 +++++++- 2 files changed, 110 insertions(+), 1 deletion(-) diff --git a/src/tui/components/__tests__/PowerlineSetup.test.ts b/src/tui/components/__tests__/PowerlineSetup.test.ts index 72339bd57..16fef5f3f 100644 --- a/src/tui/components/__tests__/PowerlineSetup.test.ts +++ b/src/tui/components/__tests__/PowerlineSetup.test.ts @@ -390,6 +390,84 @@ describe('PowerlineSetup helpers', () => { } }); + // Turning Powerline on with manual separators asks first; the separators, + // a chosen Custom theme and the padding from before all come back with it off + it('keeps manual separators, a Custom theme and the default padding when (t) turns Powerline on and off again', async () => { + const stdin = createMockStdin(); + const stdout = createMockStdout(); + const stderr = createMockStdout(); + // Each update is drawn again with the new settings, as the app does + const onUpdate = vi.fn((settings) => { + instance.rerender(React.createElement(PowerlineSetup, { ...props, settings })); + }); + const props: PowerlineSetupProps = { + settings: { + ...DEFAULT_SETTINGS, + lines: [[{ id: '1', type: 'model' }, { id: '2', type: 'separator' }, { id: '3', type: 'context-length' }]], + defaultPadding: ' ', + powerline: { + ...DEFAULT_SETTINGS.powerline, + enabled: false, + theme: 'custom' + } + }, + powerlineFontStatus: { installed: true }, + onUpdate, + onBack: vi.fn(), + onInstallFonts: vi.fn(), + installingFonts: false, + fontInstallMessage: null, + onClearMessage: vi.fn() + }; + const instance = render( + React.createElement(PowerlineSetup, props), + { + stdin, + stdout, + stderr, + debug: true, + exitOnCtrlC: false, + patchConsole: false + } + ); + + try { + await waitFor(() => { + expect(stdout.getOutput()).toContain('✗ Disabled'); + }); + + stdin.write('t'); + await waitFor(() => { + expect(stdout.getOutput()).toContain('Do you want to continue?'); + }); + + stdin.write('\r'); + await waitFor(() => { + expect(stdout.getOutput()).toContain('✓ Enabled'); + }); + const enabledSettings = onUpdate.mock.calls[0]?.[0]; + expect(enabledSettings?.lines[0]?.map(item => item.type)).toEqual(['model', 'separator', 'context-length']); + expect(enabledSettings?.powerline.theme).toBe('custom'); + + stdin.write('t'); + await waitFor(() => { + expect(onUpdate).toHaveBeenCalledTimes(2); + }); + + const disabledSettings = onUpdate.mock.calls[1]?.[0]; + expect(disabledSettings?.powerline.enabled).toBe(false); + expect(disabledSettings?.lines[0]?.map(item => item.type)).toEqual(['model', 'separator', 'context-length']); + expect(disabledSettings?.powerline.theme).toBe('custom'); + expect(disabledSettings?.defaultPadding).toBe(' '); + } finally { + instance.unmount(); + instance.cleanup(); + stdin.destroy(); + stdout.destroy(); + stderr.destroy(); + } + }); + it('shows the font install result it is given', async () => { const stdin = createMockStdin(); const stdout = createMockStdout(); diff --git a/src/utils/__tests__/powerline-settings.test.ts b/src/utils/__tests__/powerline-settings.test.ts index 56f65980c..9aa3e5cd7 100644 --- a/src/utils/__tests__/powerline-settings.test.ts +++ b/src/utils/__tests__/powerline-settings.test.ts @@ -6,7 +6,10 @@ import { import { DEFAULT_SETTINGS } from '../../types/Settings'; import type { WidgetItem } from '../../types/Widget'; -import { buildEnabledPowerlineSettings } from '../powerline-settings'; +import { + buildDisabledPowerlineSettings, + buildEnabledPowerlineSettings +} from '../powerline-settings'; describe('powerline settings helpers', () => { it('enables powerline with default theme and default padding', () => { @@ -112,4 +115,32 @@ describe('powerline settings helpers', () => { expect(disabled.lines[0]?.map(item => item.type)).toEqual(['model', 'separator', 'context-length']); }); + + it('keeps manual separators, a Custom theme and the default padding when turned on, off and on again', () => { + const line: WidgetItem[] = [ + { id: '1', type: 'model' }, + { id: '2', type: 'separator' }, + { id: '3', type: 'context-length' } + ]; + const settings = { + ...DEFAULT_SETTINGS, + defaultPadding: ' ', + powerline: { + ...DEFAULT_SETTINGS.powerline, + enabled: false, + theme: 'custom' + }, + lines: [line] + }; + + const enabled = buildEnabledPowerlineSettings(settings); + const disabled = buildDisabledPowerlineSettings(enabled); + const enabledAgain = buildEnabledPowerlineSettings(disabled); + + expect(disabled.lines[0]?.map(item => item.type)).toEqual(['model', 'separator', 'context-length']); + expect(disabled.defaultPadding).toBe(' '); + expect(disabled.powerline.theme).toBe('custom'); + expect(enabledAgain.lines[0]?.map(item => item.type)).toEqual(['model', 'separator', 'context-length']); + expect(enabledAgain.powerline.theme).toBe('custom'); + }); });