From 19db794978e18721cbb67f10243e1bf332de16ec Mon Sep 17 00:00:00 2001 From: Mark O'Keefe Date: Mon, 23 Mar 2026 14:45:37 +1100 Subject: [PATCH 01/42] feat: add git status, git remote, worktree, and custom symbol widgets Add 19 new widgets split out from PR #255 per reviewer request: - Git status widgets (addresses #20): git-status, git-staged, git-unstaged, git-untracked, git-ahead-behind, git-conflicts, git-sha - Git remote widgets: git-origin-owner, git-origin-repo, git-origin-owner-repo, git-upstream-owner, git-upstream-repo, git-upstream-owner-repo, git-is-fork - Worktree widgets: worktree-mode, worktree-name, worktree-branch, worktree-original-branch - Custom symbol widget Supporting changes: - Add git command cache and status functions to git.ts - Add git-remote utilities for fork detection and URL parsing - Add customSymbol, hide, getNumericValue to Widget types - Add worktree field to StatusJSON - Add gitData field to RenderContext Co-Authored-By: Claude Opus 4.6 (1M context) --- src/types/RenderContext.ts | 7 + src/types/StatusJSON.ts | 7 + src/types/Widget.ts | 3 + src/utils/__tests__/git-remote.test.ts | 297 ++++++++++++++++++++ src/utils/__tests__/git.test.ts | 2 + src/utils/git-remote.ts | 142 ++++++++++ src/utils/git.ts | 97 ++++++- src/utils/widget-manifest.ts | 21 +- src/widgets/CustomSymbol.tsx | 97 +++++++ src/widgets/GitAheadBehind.ts | 96 +++++++ src/widgets/GitConflicts.ts | 83 ++++++ src/widgets/GitIsFork.ts | 76 +++++ src/widgets/GitOriginOwner.ts | 70 +++++ src/widgets/GitOriginOwnerRepo.ts | 99 +++++++ src/widgets/GitOriginRepo.ts | 70 +++++ src/widgets/GitSha.ts | 59 ++++ src/widgets/GitStaged.ts | 79 ++++++ src/widgets/GitStatus.ts | 83 ++++++ src/widgets/GitUnstaged.ts | 79 ++++++ src/widgets/GitUntracked.ts | 79 ++++++ src/widgets/GitUpstreamOwner.ts | 70 +++++ src/widgets/GitUpstreamOwnerRepo.ts | 70 +++++ src/widgets/GitUpstreamRepo.ts | 70 +++++ src/widgets/WorktreeBranch.ts | 30 ++ src/widgets/WorktreeMode.ts | 36 +++ src/widgets/WorktreeName.ts | 30 ++ src/widgets/WorktreeOriginalBranch.ts | 30 ++ src/widgets/__tests__/GitBranch.test.ts | 2 + src/widgets/__tests__/GitChanges.test.ts | 2 + src/widgets/__tests__/GitDeletions.test.ts | 2 + src/widgets/__tests__/GitInsertions.test.ts | 2 + src/widgets/__tests__/GitRootDir.test.ts | 2 + src/widgets/__tests__/GitStatus.test.ts | 28 ++ src/widgets/__tests__/GitWorktree.test.ts | 2 + src/widgets/index.ts | 21 +- src/widgets/shared/git-remote.ts | 68 +++++ 36 files changed, 2007 insertions(+), 4 deletions(-) create mode 100644 src/utils/__tests__/git-remote.test.ts create mode 100644 src/utils/git-remote.ts create mode 100644 src/widgets/CustomSymbol.tsx create mode 100644 src/widgets/GitAheadBehind.ts create mode 100644 src/widgets/GitConflicts.ts create mode 100644 src/widgets/GitIsFork.ts create mode 100644 src/widgets/GitOriginOwner.ts create mode 100644 src/widgets/GitOriginOwnerRepo.ts create mode 100644 src/widgets/GitOriginRepo.ts create mode 100644 src/widgets/GitSha.ts create mode 100644 src/widgets/GitStaged.ts create mode 100644 src/widgets/GitStatus.ts create mode 100644 src/widgets/GitUnstaged.ts create mode 100644 src/widgets/GitUntracked.ts create mode 100644 src/widgets/GitUpstreamOwner.ts create mode 100644 src/widgets/GitUpstreamOwnerRepo.ts create mode 100644 src/widgets/GitUpstreamRepo.ts create mode 100644 src/widgets/WorktreeBranch.ts create mode 100644 src/widgets/WorktreeMode.ts create mode 100644 src/widgets/WorktreeName.ts create mode 100644 src/widgets/WorktreeOriginalBranch.ts create mode 100644 src/widgets/__tests__/GitStatus.test.ts create mode 100644 src/widgets/shared/git-remote.ts diff --git a/src/types/RenderContext.ts b/src/types/RenderContext.ts index 9ba86aea..7d811869 100644 --- a/src/types/RenderContext.ts +++ b/src/types/RenderContext.ts @@ -32,4 +32,11 @@ export interface RenderContext { isPreview?: boolean; lineIndex?: number; // Index of the current line being rendered (for theme cycling) globalSeparatorIndex?: number; // Global separator index that continues across lines + + // For git widget thresholds + gitData?: { + changedFiles?: number; + insertions?: number; + deletions?: number; + }; } \ No newline at end of file diff --git a/src/types/StatusJSON.ts b/src/types/StatusJSON.ts index 6846cc20..374806da 100644 --- a/src/types/StatusJSON.ts +++ b/src/types/StatusJSON.ts @@ -61,6 +61,13 @@ export const StatusJSONSchema = z.looseObject({ remaining_percentage: CoercedNumberSchema.nullable().optional() }).nullable().optional(), vim: z.object({ mode: z.string().optional() }).nullable().optional(), + worktree: z.object({ + name: z.string().optional(), + path: z.string().optional(), + branch: z.string().optional(), + original_cwd: z.string().optional(), + original_branch: z.string().optional() + }).nullable().optional(), rate_limits: z.object({ five_hour: RateLimitPeriodSchema.optional(), seven_day: RateLimitPeriodSchema.optional() diff --git a/src/types/Widget.ts b/src/types/Widget.ts index fd3e5f83..a62af252 100644 --- a/src/types/Widget.ts +++ b/src/types/Widget.ts @@ -13,11 +13,13 @@ export const WidgetItemSchema = z.object({ character: z.string().optional(), rawValue: z.boolean().optional(), customText: z.string().optional(), + customSymbol: z.string().optional(), commandPath: z.string().optional(), maxWidth: z.number().optional(), preserveColors: z.boolean().optional(), timeout: z.number().optional(), merge: z.union([z.boolean(), z.literal('no-padding')]).optional(), + hide: z.boolean().optional(), metadata: z.record(z.string(), z.string()).optional() }); @@ -42,6 +44,7 @@ export interface Widget { supportsRawValue(): boolean; supportsColors(item: WidgetItem): boolean; handleEditorAction?(action: string, item: WidgetItem): WidgetItem | null; + getNumericValue?(context: RenderContext, item: WidgetItem): number | null; } export interface WidgetEditorProps { diff --git a/src/utils/__tests__/git-remote.test.ts b/src/utils/__tests__/git-remote.test.ts new file mode 100644 index 00000000..55581d09 --- /dev/null +++ b/src/utils/__tests__/git-remote.test.ts @@ -0,0 +1,297 @@ +import { execSync } from 'child_process'; +import { + beforeEach, + describe, + expect, + it, + vi +} from 'vitest'; + +import type { RenderContext } from '../../types/RenderContext'; +import { clearGitCache } from '../git'; +import { + buildRepoWebUrl, + getForkStatus, + getRemoteInfo, + listRemotes, + parseRemoteUrl +} from '../git-remote'; + +vi.mock('child_process', () => ({ execSync: vi.fn() })); + +const mockExecSync = execSync as unknown as { + mock: { calls: unknown[][] }; + mockImplementation: (impl: () => never) => void; + mockReturnValue: (value: string) => void; + mockReturnValueOnce: (value: string) => void; +}; + +describe('git-remote utils', () => { + beforeEach(() => { + vi.clearAllMocks(); + clearGitCache(); + }); + + describe('parseRemoteUrl', () => { + describe('SSH format (git@host:owner/repo)', () => { + it('parses github.com SSH URL', () => { + expect(parseRemoteUrl('git@github.com:owner/repo.git')).toEqual({ + host: 'github.com', + owner: 'owner', + repo: 'repo' + }); + }); + + it('parses GHES SSH URL', () => { + expect(parseRemoteUrl('git@github.service.anz:org/project.git')).toEqual({ + host: 'github.service.anz', + owner: 'org', + repo: 'project' + }); + }); + + it('parses SSH URL without .git suffix', () => { + expect(parseRemoteUrl('git@github.com:owner/repo')).toEqual({ + host: 'github.com', + owner: 'owner', + repo: 'repo' + }); + }); + + it('parses SSH URL with trailing slash', () => { + expect(parseRemoteUrl('git@github.com:owner/repo/')).toEqual({ + host: 'github.com', + owner: 'owner', + repo: 'repo' + }); + }); + + it('parses GitLab SSH URL', () => { + expect(parseRemoteUrl('git@gitlab.com:group/project.git')).toEqual({ + host: 'gitlab.com', + owner: 'group', + repo: 'project' + }); + }); + }); + + describe('HTTPS format', () => { + it('parses github.com HTTPS URL', () => { + expect(parseRemoteUrl('https://github.com/owner/repo.git')).toEqual({ + host: 'github.com', + owner: 'owner', + repo: 'repo' + }); + }); + + it('parses GHES HTTPS URL', () => { + expect(parseRemoteUrl('https://github.service.anz/org/project.git')).toEqual({ + host: 'github.service.anz', + owner: 'org', + repo: 'project' + }); + }); + + it('parses HTTPS URL without .git suffix', () => { + expect(parseRemoteUrl('https://github.com/owner/repo')).toEqual({ + host: 'github.com', + owner: 'owner', + repo: 'repo' + }); + }); + + it('parses HTTP URL', () => { + expect(parseRemoteUrl('http://github.com/owner/repo.git')).toEqual({ + host: 'github.com', + owner: 'owner', + repo: 'repo' + }); + }); + }); + + describe('ssh:// protocol format', () => { + it('parses ssh:// URL', () => { + expect(parseRemoteUrl('ssh://git@github.com/owner/repo.git')).toEqual({ + host: 'github.com', + owner: 'owner', + repo: 'repo' + }); + }); + + it('parses ssh:// URL for GHES', () => { + expect(parseRemoteUrl('ssh://git@github.service.anz/org/project.git')).toEqual({ + host: 'github.service.anz', + owner: 'org', + repo: 'project' + }); + }); + }); + + describe('git:// protocol format', () => { + it('parses git:// URL', () => { + expect(parseRemoteUrl('git://github.com/owner/repo.git')).toEqual({ + host: 'github.com', + owner: 'owner', + repo: 'repo' + }); + }); + }); + + describe('edge cases', () => { + it('returns null for empty string', () => { + expect(parseRemoteUrl('')).toBeNull(); + }); + + it('returns null for whitespace-only string', () => { + expect(parseRemoteUrl(' ')).toBeNull(); + }); + + it('returns null for invalid URL', () => { + expect(parseRemoteUrl('not-a-url')).toBeNull(); + }); + + it('returns null for URL with only owner (no repo)', () => { + expect(parseRemoteUrl('https://github.com/owner')).toBeNull(); + }); + + it('returns null for unsupported protocol', () => { + expect(parseRemoteUrl('ftp://github.com/owner/repo.git')).toBeNull(); + }); + + it('trims whitespace from URL', () => { + expect(parseRemoteUrl(' https://github.com/owner/repo.git ')).toEqual({ + host: 'github.com', + owner: 'owner', + repo: 'repo' + }); + }); + }); + }); + + describe('getRemoteInfo', () => { + it('returns remote info for valid remote', () => { + mockExecSync.mockReturnValue('https://github.com/hangie/ccstatusline.git\n'); + const context: RenderContext = { data: { cwd: '/tmp/repo' } }; + + const result = getRemoteInfo('origin', context); + + expect(result).toEqual({ + name: 'origin', + url: 'https://github.com/hangie/ccstatusline.git', + host: 'github.com', + owner: 'hangie', + repo: 'ccstatusline' + }); + }); + + it('returns null when remote does not exist', () => { + mockExecSync.mockImplementation(() => { throw new Error('No such remote'); }); + + expect(getRemoteInfo('nonexistent', {})).toBeNull(); + }); + + it('returns null when URL cannot be parsed', () => { + mockExecSync.mockReturnValue('invalid-url\n'); + + expect(getRemoteInfo('origin', {})).toBeNull(); + }); + }); + + describe('getForkStatus', () => { + it('detects fork when origin and upstream differ', () => { + mockExecSync.mockReturnValueOnce('https://github.com/hangie/ccstatusline.git\n'); + mockExecSync.mockReturnValueOnce('https://github.com/sirmalloc/ccstatusline.git\n'); + + const result = getForkStatus({}); + + expect(result.isFork).toBe(true); + expect(result.origin?.owner).toBe('hangie'); + expect(result.upstream?.owner).toBe('sirmalloc'); + }); + + it('detects fork when repos have different names', () => { + mockExecSync.mockReturnValueOnce('https://github.com/hangie/my-fork.git\n'); + mockExecSync.mockReturnValueOnce('https://github.com/hangie/original.git\n'); + + const result = getForkStatus({}); + + expect(result.isFork).toBe(true); + }); + + it('returns not a fork when only origin exists', () => { + mockExecSync.mockReturnValueOnce('https://github.com/owner/repo.git\n'); + mockExecSync.mockImplementation(() => { throw new Error('No such remote'); }); + + const result = getForkStatus({}); + + expect(result.isFork).toBe(false); + expect(result.origin).not.toBeNull(); + expect(result.upstream).toBeNull(); + }); + + it('returns not a fork when origin equals upstream', () => { + mockExecSync.mockReturnValueOnce('https://github.com/owner/repo.git\n'); + mockExecSync.mockReturnValueOnce('https://github.com/owner/repo.git\n'); + + const result = getForkStatus({}); + + expect(result.isFork).toBe(false); + }); + + it('returns not a fork when no remotes exist', () => { + mockExecSync.mockImplementation(() => { throw new Error('No such remote'); }); + + const result = getForkStatus({}); + + expect(result.isFork).toBe(false); + expect(result.origin).toBeNull(); + expect(result.upstream).toBeNull(); + }); + }); + + describe('listRemotes', () => { + it('returns list of remote names', () => { + mockExecSync.mockReturnValue('origin\nupstream\n'); + + expect(listRemotes({})).toEqual(['origin', 'upstream']); + }); + + it('returns empty array when no remotes', () => { + mockExecSync.mockImplementation(() => { throw new Error('Not a git repo'); }); + + expect(listRemotes({})).toEqual([]); + }); + + it('filters empty lines', () => { + mockExecSync.mockReturnValue('origin\n\nupstream\n\n'); + + expect(listRemotes({})).toEqual(['origin', 'upstream']); + }); + }); + + describe('buildRepoWebUrl', () => { + it('builds URL for github.com', () => { + const remote = { + name: 'origin', + url: 'git@github.com:owner/repo.git', + host: 'github.com', + owner: 'owner', + repo: 'repo' + }; + + expect(buildRepoWebUrl(remote)).toBe('https://github.com/owner/repo'); + }); + + it('builds URL for GHES', () => { + const remote = { + name: 'origin', + url: 'git@github.service.anz:org/project.git', + host: 'github.service.anz', + owner: 'org', + repo: 'project' + }; + + expect(buildRepoWebUrl(remote)).toBe('https://github.service.anz/org/project'); + }); + }); +}); \ No newline at end of file diff --git a/src/utils/__tests__/git.test.ts b/src/utils/__tests__/git.test.ts index 74ddb314..207e735e 100644 --- a/src/utils/__tests__/git.test.ts +++ b/src/utils/__tests__/git.test.ts @@ -9,6 +9,7 @@ import { import type { RenderContext } from '../../types/RenderContext'; import { + clearGitCache, getGitChangeCounts, isInsideGitWorkTree, resolveGitCwd, @@ -27,6 +28,7 @@ const mockExecSync = execSync as unknown as { describe('git utils', () => { beforeEach(() => { vi.clearAllMocks(); + clearGitCache(); }); describe('resolveGitCwd', () => { diff --git a/src/utils/git-remote.ts b/src/utils/git-remote.ts new file mode 100644 index 00000000..69c56e0f --- /dev/null +++ b/src/utils/git-remote.ts @@ -0,0 +1,142 @@ +import type { RenderContext } from '../types/RenderContext'; + +import { runGit } from './git'; + +export interface RemoteInfo { + name: string; + url: string; + host: string; + owner: string; + repo: string; +} + +export interface ForkStatus { + isFork: boolean; + origin: RemoteInfo | null; + upstream: RemoteInfo | null; +} + +/** + * Extract owner and repo from a git remote URL. + * Supports SSH, HTTPS, git://, and ssh:// formats. + * Works with any git host (GitHub, GHES, GHEC, GitLab, etc.) + * + * Examples: + * - git@github.com:owner/repo.git + * - https://github.com/owner/repo.git + * - git@github.service.anz:owner/repo.git + * - ssh://git@github.com/owner/repo.git + * - git://github.com/owner/repo + */ +export function parseRemoteUrl(url: string): { host: string; owner: string; repo: string } | null { + const trimmed = url.trim(); + if (trimmed.length === 0) { + return null; + } + + // SSH format: git@host:owner/repo.git or user@host:owner/repo.git + const sshMatch = /^(?:[^@]+@)?([^:]+):([^/]+)\/([^/]+?)(?:\.git)?\/?$/.exec(trimmed); + if (sshMatch?.[1] && sshMatch[2] && sshMatch[3]) { + return { + host: sshMatch[1], + owner: sshMatch[2], + repo: sshMatch[3] + }; + } + + // URL format: https://host/owner/repo.git, ssh://git@host/owner/repo.git, git://host/owner/repo + try { + const parsedUrl = new URL(trimmed); + const supportedProtocols = new Set(['http:', 'https:', 'ssh:', 'git:']); + + if (!supportedProtocols.has(parsedUrl.protocol)) { + return null; + } + + // Remove leading/trailing slashes and .git suffix + const pathname = parsedUrl.pathname.replace(/^\/+|\/+$/g, '').replace(/\.git$/, ''); + const segments = pathname.split('/').filter(Boolean); + + const owner = segments[0]; + const repo = segments[1]; + + if (!owner || !repo) { + return null; + } + + return { + host: parsedUrl.hostname, + owner, + repo + }; + } catch { + return null; + } +} + +/** + * Get information about a specific remote. + */ +export function getRemoteInfo(remoteName: string, context: RenderContext): RemoteInfo | null { + const url = runGit(`remote get-url ${remoteName}`, context); + if (!url) { + return null; + } + + const parsed = parseRemoteUrl(url); + if (!parsed) { + return null; + } + + return { + name: remoteName, + url, + host: parsed.host, + owner: parsed.owner, + repo: parsed.repo + }; +} + +/** + * Get fork status by checking origin and upstream remotes. + * A repository is considered a fork if: + * 1. Both origin and upstream remotes exist + * 2. They point to different owner/repo combinations + */ +export function getForkStatus(context: RenderContext): ForkStatus { + const origin = getRemoteInfo('origin', context); + const upstream = getRemoteInfo('upstream', context); + + const isFork = Boolean( + origin + && upstream + && (origin.owner !== upstream.owner || origin.repo !== upstream.repo) + ); + + return { + isFork, + origin, + upstream + }; +} + +/** + * List all remote names. + */ +export function listRemotes(context: RenderContext): string[] { + const output = runGit('remote', context); + if (!output) { + return []; + } + + return output.split('\n').filter(Boolean); +} + +/** + * Build a web URL for a repository on GitHub-like hosts. + * Returns null if the host doesn't appear to be GitHub-like. + */ +export function buildRepoWebUrl(remote: RemoteInfo): string { + // Assume HTTPS for the web URL + return `https://${remote.host}/${remote.owner}/${remote.repo}`; +} \ No newline at end of file diff --git a/src/utils/git.ts b/src/utils/git.ts index e73b9999..dca0aad2 100644 --- a/src/utils/git.ts +++ b/src/utils/git.ts @@ -7,6 +7,9 @@ export interface GitChangeCounts { deletions: number; } +// Cache for git commands - key is "command|cwd" +const gitCommandCache = new Map(); + export function resolveGitCwd(context: RenderContext): string | undefined { const candidates = [ context.data?.cwd, @@ -24,20 +27,37 @@ export function resolveGitCwd(context: RenderContext): string | undefined { } export function runGit(command: string, context: RenderContext): string | null { + const cwd = resolveGitCwd(context); + const cacheKey = `${command}|${cwd ?? ''}`; + + // Check cache first + if (gitCommandCache.has(cacheKey)) { + return gitCommandCache.get(cacheKey) ?? null; + } + try { - const cwd = resolveGitCwd(context); const output = execSync(`git ${command}`, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'], ...(cwd ? { cwd } : {}) }).trim(); - return output.length > 0 ? output : null; + const result = output.length > 0 ? output : null; + gitCommandCache.set(cacheKey, result); + return result; } catch { + gitCommandCache.set(cacheKey, null); return null; } } +/** + * Clear git command cache - for testing only + */ +export function clearGitCache(): void { + gitCommandCache.clear(); +} + export function isInsideGitWorkTree(context: RenderContext): boolean { return runGit('rev-parse --is-inside-work-tree', context) === 'true'; } @@ -62,4 +82,77 @@ export function getGitChangeCounts(context: RenderContext): GitChangeCounts { insertions: unstagedCounts.insertions + stagedCounts.insertions, deletions: unstagedCounts.deletions + stagedCounts.deletions }; +} + +export interface GitStatus { + staged: boolean; + unstaged: boolean; + untracked: boolean; +} + +export function getGitStatus(context: RenderContext): GitStatus { + const output = runGit('--no-optional-locks status --porcelain', context); + + if (!output) { + return { staged: false, unstaged: false, untracked: false }; + } + + let staged = false; + let unstaged = false; + let untracked = false; + + for (const line of output.split('\n')) { + if (line.length < 2) + continue; + if (!staged && /^[MADRCTU]/.test(line)) + staged = true; + if (!unstaged && /^.[MD]/.test(line)) + unstaged = true; + if (!untracked && line.startsWith('??')) + untracked = true; + if (staged && unstaged && untracked) + break; + } + + return { staged, unstaged, untracked }; +} + +export interface GitAheadBehind { + ahead: number; + behind: number; +} + +export function getGitAheadBehind(context: RenderContext): GitAheadBehind | null { + const output = runGit('rev-list --left-right --count HEAD...@{upstream}', context); + if (!output) + return null; + + const parts = output.split(/\s+/); + if (parts.length !== 2 || !parts[0] || !parts[1]) + return null; + + const ahead = parseInt(parts[0], 10); + const behind = parseInt(parts[1], 10); + + if (isNaN(ahead) || isNaN(behind)) + return null; + + return { ahead, behind }; +} + +export function getGitConflictCount(context: RenderContext): number { + const output = runGit('ls-files --unmerged', context); + if (!output) + return 0; + + // Count unique file paths (unmerged files appear 3 times in output) + const files = new Set(output.split('\n').map((line) => { + const parts = line.split(/\s+/).slice(3); + return parts.join(' '); + }).filter(path => path.length > 0)); + return files.size; +} + +export function getGitShortSha(context: RenderContext): string | null { + return runGit('rev-parse --short HEAD', context); } \ No newline at end of file diff --git a/src/utils/widget-manifest.ts b/src/utils/widget-manifest.ts index 890abd9f..56022fa3 100644 --- a/src/utils/widget-manifest.ts +++ b/src/utils/widget-manifest.ts @@ -25,6 +25,20 @@ export const WIDGET_MANIFEST: WidgetManifestEntry[] = [ { type: 'git-deletions', create: () => new widgets.GitDeletionsWidget() }, { type: 'git-root-dir', create: () => new widgets.GitRootDirWidget() }, { type: 'git-worktree', create: () => new widgets.GitWorktreeWidget() }, + { type: 'git-status', create: () => new widgets.GitStatusWidget() }, + { type: 'git-staged', create: () => new widgets.GitStagedWidget() }, + { type: 'git-unstaged', create: () => new widgets.GitUnstagedWidget() }, + { type: 'git-untracked', create: () => new widgets.GitUntrackedWidget() }, + { type: 'git-ahead-behind', create: () => new widgets.GitAheadBehindWidget() }, + { type: 'git-conflicts', create: () => new widgets.GitConflictsWidget() }, + { type: 'git-sha', create: () => new widgets.GitShaWidget() }, + { type: 'git-origin-owner', create: () => new widgets.GitOriginOwnerWidget() }, + { type: 'git-origin-repo', create: () => new widgets.GitOriginRepoWidget() }, + { type: 'git-origin-owner-repo', create: () => new widgets.GitOriginOwnerRepoWidget() }, + { type: 'git-upstream-owner', create: () => new widgets.GitUpstreamOwnerWidget() }, + { type: 'git-upstream-repo', create: () => new widgets.GitUpstreamRepoWidget() }, + { type: 'git-upstream-owner-repo', create: () => new widgets.GitUpstreamOwnerRepoWidget() }, + { type: 'git-is-fork', create: () => new widgets.GitIsForkWidget() }, { type: 'current-working-dir', create: () => new widgets.CurrentWorkingDirWidget() }, { type: 'tokens-input', create: () => new widgets.TokensInputWidget() }, { type: 'tokens-output', create: () => new widgets.TokensOutputWidget() }, @@ -42,6 +56,7 @@ export const WIDGET_MANIFEST: WidgetManifestEntry[] = [ { type: 'terminal-width', create: () => new widgets.TerminalWidthWidget() }, { type: 'version', create: () => new widgets.VersionWidget() }, { type: 'custom-text', create: () => new widgets.CustomTextWidget() }, + { type: 'custom-symbol', create: () => new widgets.CustomSymbolWidget() }, { type: 'custom-command', create: () => new widgets.CustomCommandWidget() }, { type: 'link', create: () => new widgets.LinkWidget() }, { type: 'claude-session-id', create: () => new widgets.ClaudeSessionIdWidget() }, @@ -54,7 +69,11 @@ export const WIDGET_MANIFEST: WidgetManifestEntry[] = [ { type: 'context-bar', create: () => new widgets.ContextBarWidget() }, { type: 'skills', create: () => new widgets.SkillsWidget() }, { type: 'thinking-effort', create: () => new widgets.ThinkingEffortWidget() }, - { type: 'vim-mode', create: () => new widgets.VimModeWidget() } + { type: 'vim-mode', create: () => new widgets.VimModeWidget() }, + { type: 'worktree-mode', create: () => new widgets.WorktreeModeWidget() }, + { type: 'worktree-name', create: () => new widgets.WorktreeNameWidget() }, + { type: 'worktree-branch', create: () => new widgets.WorktreeBranchWidget() }, + { type: 'worktree-original-branch', create: () => new widgets.WorktreeOriginalBranchWidget() } ]; export const LAYOUT_WIDGET_MANIFEST: LayoutWidgetManifestEntry[] = [ diff --git a/src/widgets/CustomSymbol.tsx b/src/widgets/CustomSymbol.tsx new file mode 100644 index 00000000..47809b05 --- /dev/null +++ b/src/widgets/CustomSymbol.tsx @@ -0,0 +1,97 @@ +import { + Box, + Text, + useInput +} from 'ink'; +import React, { useState } from 'react'; + +import type { RenderContext } from '../types/RenderContext'; +import type { Settings } from '../types/Settings'; +import type { + CustomKeybind, + Widget, + WidgetEditorDisplay, + WidgetEditorProps, + WidgetItem +} from '../types/Widget'; +import { shouldInsertInput } from '../utils/input-guards'; + +export class CustomSymbolWidget implements Widget { + getDefaultColor(): string { return 'white'; } + getDescription(): string { return 'Displays a custom symbol or emoji (single character)'; } + getDisplayName(): string { return 'Custom Symbol'; } + getCategory(): string { return 'Custom'; } + + getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { + const symbol = item.customSymbol ?? '?'; + return { displayText: `${this.getDisplayName()} (${symbol})` }; + } + + render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { + return item.customSymbol ?? ''; + } + + getCustomKeybinds(): CustomKeybind[] { + return [{ + key: 'e', + label: '(e)dit symbol', + action: 'edit-symbol' + }]; + } + + renderEditor(props: WidgetEditorProps): React.ReactElement { + return ; + } + + supportsRawValue(): boolean { return false; } + supportsColors(item: WidgetItem): boolean { return true; } +} + +const CustomSymbolEditor: React.FC = ({ widget, onComplete, onCancel }) => { + const [symbol, setSymbol] = useState(widget.customSymbol ?? ''); + + // Helper to get grapheme segments if Intl.Segmenter is available + const getFirstGrapheme = (str: string): string => { + if (str.length === 0) { + return ''; + } + + if ('Segmenter' in Intl) { + const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' }); + const segments = Array.from(segmenter.segment(str)); + return segments[0]?.segment ?? ''; + } + + // Fallback: just take first character + return Array.from(str)[0] ?? ''; + }; + + useInput((input, key) => { + if (key.return) { + onComplete({ ...widget, customSymbol: symbol }); + } else if (key.escape) { + onCancel(); + } else if (key.backspace || key.delete) { + setSymbol(''); + } else if (shouldInsertInput(input, key)) { + // Take only the first grapheme (handles multi-byte emojis correctly) + const firstGrapheme = getFirstGrapheme(input); + setSymbol(firstGrapheme); + } + }); + + return ( + + + Enter custom symbol: + {' '} + {symbol ? ( + {symbol} + ) : ( + (empty) + )} + + Type any character or emoji, Backspace clear, Enter save, ESC cancel + + ); +}; \ No newline at end of file diff --git a/src/widgets/GitAheadBehind.ts b/src/widgets/GitAheadBehind.ts new file mode 100644 index 00000000..ac5b5d59 --- /dev/null +++ b/src/widgets/GitAheadBehind.ts @@ -0,0 +1,96 @@ +import type { RenderContext } from '../types/RenderContext'; +import type { Settings } from '../types/Settings'; +import type { + CustomKeybind, + Widget, + WidgetEditorDisplay, + WidgetItem +} from '../types/Widget'; +import { + getGitAheadBehind, + isInsideGitWorkTree +} from '../utils/git'; + +import { makeModifierText } from './shared/editor-display'; +import { + getHideNoGitKeybinds, + getHideNoGitModifierText, + handleToggleNoGitAction, + isHideNoGitEnabled +} from './shared/git-no-git'; + +export class GitAheadBehindWidget implements Widget { + getDefaultColor(): string { return 'cyan'; } + getDescription(): string { return 'Shows commits ahead/behind upstream (↑2↓3)'; } + getDisplayName(): string { return 'Git Ahead/Behind'; } + getCategory(): string { return 'Git'; } + + getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { + const modifiers: string[] = []; + const noGitText = getHideNoGitModifierText(item); + if (noGitText) + modifiers.push('hide \'no git\''); + + return { + displayText: this.getDisplayName(), + modifierText: makeModifierText(modifiers) + }; + } + + handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { + return handleToggleNoGitAction(action, item); + } + + render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { + const hideNoGit = isHideNoGitEnabled(item); + + if (context.isPreview) { + if (item.rawValue) + return '2,3'; + return '↑2↓3'; + } + + if (!isInsideGitWorkTree(context)) { + return hideNoGit ? null : '(no git)'; + } + + const result = getGitAheadBehind(context); + if (!result) { + return hideNoGit ? null : '(no upstream)'; + } + + // Hide if both are zero + if (result.ahead === 0 && result.behind === 0) { + return null; + } + + if (item.rawValue) { + return `${result.ahead},${result.behind}`; + } + + const parts: string[] = []; + if (result.ahead > 0) + parts.push(`↑${result.ahead}`); + if (result.behind > 0) + parts.push(`↓${result.behind}`); + + return parts.join(''); + } + + getCustomKeybinds(): CustomKeybind[] { + return getHideNoGitKeybinds(); + } + + getNumericValue(context: RenderContext, _item: WidgetItem): number | null { + if (!isInsideGitWorkTree(context)) + return null; + const result = getGitAheadBehind(context); + if (!result) + return null; + // Return total divergence (ahead + behind) + return result.ahead + result.behind; + } + + supportsRawValue(): boolean { return true; } + supportsColors(_item: WidgetItem): boolean { return true; } +} \ No newline at end of file diff --git a/src/widgets/GitConflicts.ts b/src/widgets/GitConflicts.ts new file mode 100644 index 00000000..cf80983f --- /dev/null +++ b/src/widgets/GitConflicts.ts @@ -0,0 +1,83 @@ +import type { RenderContext } from '../types/RenderContext'; +import type { Settings } from '../types/Settings'; +import type { + CustomKeybind, + Widget, + WidgetEditorDisplay, + WidgetItem +} from '../types/Widget'; +import { + getGitConflictCount, + isInsideGitWorkTree +} from '../utils/git'; + +import { makeModifierText } from './shared/editor-display'; +import { + getHideNoGitKeybinds, + getHideNoGitModifierText, + handleToggleNoGitAction, + isHideNoGitEnabled +} from './shared/git-no-git'; + +export class GitConflictsWidget implements Widget { + getDefaultColor(): string { return 'red'; } + getDescription(): string { return 'Shows count of merge conflicts'; } + getDisplayName(): string { return 'Git Conflicts'; } + getCategory(): string { return 'Git'; } + + getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { + const modifiers: string[] = []; + const noGitText = getHideNoGitModifierText(item); + if (noGitText) + modifiers.push('hide \'no git\''); + + return { + displayText: this.getDisplayName(), + modifierText: makeModifierText(modifiers) + }; + } + + handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { + return handleToggleNoGitAction(action, item); + } + + render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { + const hideNoGit = isHideNoGitEnabled(item); + + if (context.isPreview) { + if (item.rawValue) + return 'true'; + return '⚠ 2'; + } + + if (!isInsideGitWorkTree(context)) { + return hideNoGit ? null : '(no git)'; + } + + const count = getGitConflictCount(context); + + if (count === 0) { + return null; + } + + if (item.rawValue) { + return 'true'; + } + + return `⚠ ${count}`; + } + + getCustomKeybinds(): CustomKeybind[] { + return getHideNoGitKeybinds(); + } + + getNumericValue(context: RenderContext, _item: WidgetItem): number | null { + if (!isInsideGitWorkTree(context)) + return null; + const count = getGitConflictCount(context); + return count > 0 ? 1 : 0; + } + + supportsRawValue(): boolean { return true; } + supportsColors(_item: WidgetItem): boolean { return true; } +} \ No newline at end of file diff --git a/src/widgets/GitIsFork.ts b/src/widgets/GitIsFork.ts new file mode 100644 index 00000000..6f0b1cdd --- /dev/null +++ b/src/widgets/GitIsFork.ts @@ -0,0 +1,76 @@ +import type { RenderContext } from '../types/RenderContext'; +import type { Settings } from '../types/Settings'; +import type { + CustomKeybind, + Widget, + WidgetEditorDisplay, + WidgetItem +} from '../types/Widget'; +import { getForkStatus } from '../utils/git-remote'; + +import { makeModifierText } from './shared/editor-display'; +import { + isMetadataFlagEnabled, + toggleMetadataFlag +} from './shared/metadata'; + +const HIDE_WHEN_NOT_FORK_KEY = 'hideWhenNotFork'; +const TOGGLE_HIDE_ACTION = 'toggle-hide'; + +export class GitIsForkWidget implements Widget { + getDefaultColor(): string { return 'yellow'; } + getDescription(): string { return 'Shows fork indicator when repo is a fork of upstream'; } + getDisplayName(): string { return 'Git Is Fork'; } + getCategory(): string { return 'Git'; } + + getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { + const modifiers: string[] = []; + + if (isMetadataFlagEnabled(item, HIDE_WHEN_NOT_FORK_KEY)) { + modifiers.push('hide when not fork'); + } + + return { + displayText: this.getDisplayName(), + modifierText: makeModifierText(modifiers) + }; + } + + handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { + if (action === TOGGLE_HIDE_ACTION) { + return toggleMetadataFlag(item, HIDE_WHEN_NOT_FORK_KEY); + } + + return null; + } + + render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { + const hideWhenNotFork = isMetadataFlagEnabled(item, HIDE_WHEN_NOT_FORK_KEY); + + if (context.isPreview) { + return item.rawValue ? 'true' : 'isFork: true'; + } + + const forkStatus = getForkStatus(context); + + if (forkStatus.isFork) { + return item.rawValue ? 'true' : 'isFork: true'; + } + + // Not a fork + if (hideWhenNotFork) { + return null; + } + + return item.rawValue ? 'false' : 'isFork: false'; + } + + getCustomKeybinds(): CustomKeybind[] { + return [ + { key: 'h', label: '(h)ide when not fork', action: TOGGLE_HIDE_ACTION } + ]; + } + + supportsRawValue(): boolean { return true; } + supportsColors(_item: WidgetItem): boolean { return true; } +} \ No newline at end of file diff --git a/src/widgets/GitOriginOwner.ts b/src/widgets/GitOriginOwner.ts new file mode 100644 index 00000000..7ed7200b --- /dev/null +++ b/src/widgets/GitOriginOwner.ts @@ -0,0 +1,70 @@ +import type { RenderContext } from '../types/RenderContext'; +import type { Settings } from '../types/Settings'; +import type { + CustomKeybind, + Widget, + WidgetEditorDisplay, + WidgetItem +} from '../types/Widget'; +import { + buildRepoWebUrl, + getRemoteInfo +} from '../utils/git-remote'; +import { renderOsc8Link } from '../utils/hyperlink'; + +import { + getRemoteWidgetKeybinds, + getRemoteWidgetModifierText, + handleRemoteWidgetAction, + isHideNoRemoteEnabled, + isLinkToRepoEnabled +} from './shared/git-remote'; + +export class GitOriginOwnerWidget implements Widget { + getDefaultColor(): string { return 'cyan'; } + getDescription(): string { return 'Shows the origin remote owner/organization'; } + getDisplayName(): string { return 'Git Origin Owner'; } + getCategory(): string { return 'Git'; } + + getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { + return { + displayText: this.getDisplayName(), + modifierText: getRemoteWidgetModifierText(item) + }; + } + + handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { + return handleRemoteWidgetAction(action, item); + } + + render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { + const hideWhenEmpty = isHideNoRemoteEnabled(item); + const linkEnabled = isLinkToRepoEnabled(item); + + if (context.isPreview) { + const text = 'owner'; + return linkEnabled ? renderOsc8Link('https://github.com/owner/repo', text) : text; + } + + const origin = getRemoteInfo('origin', context); + if (!origin) { + return hideWhenEmpty ? null : 'no remote'; + } + + const text = origin.owner; + + if (linkEnabled) { + const url = buildRepoWebUrl(origin); + return renderOsc8Link(url, text); + } + + return text; + } + + getCustomKeybinds(): CustomKeybind[] { + return getRemoteWidgetKeybinds(); + } + + supportsRawValue(): boolean { return false; } + supportsColors(_item: WidgetItem): boolean { return true; } +} \ No newline at end of file diff --git a/src/widgets/GitOriginOwnerRepo.ts b/src/widgets/GitOriginOwnerRepo.ts new file mode 100644 index 00000000..0c0cf72e --- /dev/null +++ b/src/widgets/GitOriginOwnerRepo.ts @@ -0,0 +1,99 @@ +import type { RenderContext } from '../types/RenderContext'; +import type { Settings } from '../types/Settings'; +import type { + CustomKeybind, + Widget, + WidgetEditorDisplay, + WidgetItem +} from '../types/Widget'; +import { + buildRepoWebUrl, + getForkStatus, + getRemoteInfo +} from '../utils/git-remote'; +import { renderOsc8Link } from '../utils/hyperlink'; + +import { makeModifierText } from './shared/editor-display'; +import { + getRemoteWidgetKeybinds, + handleRemoteWidgetAction, + isHideNoRemoteEnabled, + isLinkToRepoEnabled +} from './shared/git-remote'; +import { + isMetadataFlagEnabled, + toggleMetadataFlag +} from './shared/metadata'; + +const OWNER_ONLY_WHEN_FORK_KEY = 'ownerOnlyWhenFork'; +const TOGGLE_OWNER_ONLY_ACTION = 'toggle-owner-only'; + +export class GitOriginOwnerRepoWidget implements Widget { + getDefaultColor(): string { return 'cyan'; } + getDescription(): string { return 'Shows the origin remote as owner/repo'; } + getDisplayName(): string { return 'Git Origin Owner/Repo'; } + getCategory(): string { return 'Git'; } + + getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { + const modifiers: string[] = []; + + if (isHideNoRemoteEnabled(item)) { + modifiers.push('hide when empty'); + } + if (isLinkToRepoEnabled(item)) { + modifiers.push('link'); + } + if (isMetadataFlagEnabled(item, OWNER_ONLY_WHEN_FORK_KEY)) { + modifiers.push('owner only when fork'); + } + + return { + displayText: this.getDisplayName(), + modifierText: makeModifierText(modifiers) + }; + } + + handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { + if (action === TOGGLE_OWNER_ONLY_ACTION) { + return toggleMetadataFlag(item, OWNER_ONLY_WHEN_FORK_KEY); + } + + return handleRemoteWidgetAction(action, item); + } + + render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { + const hideWhenEmpty = isHideNoRemoteEnabled(item); + const linkEnabled = isLinkToRepoEnabled(item); + const ownerOnlyWhenFork = isMetadataFlagEnabled(item, OWNER_ONLY_WHEN_FORK_KEY); + + if (context.isPreview) { + const text = ownerOnlyWhenFork ? 'owner' : 'owner/repo'; + return linkEnabled ? renderOsc8Link('https://github.com/owner/repo', text) : text; + } + + const origin = getRemoteInfo('origin', context); + if (!origin) { + return hideWhenEmpty ? null : 'no remote'; + } + + const isFork = ownerOnlyWhenFork && getForkStatus(context).isFork; + const text = isFork ? origin.owner : `${origin.owner}/${origin.repo}`; + + if (linkEnabled) { + const url = buildRepoWebUrl(origin); + return renderOsc8Link(url, text); + } + + return text; + } + + getCustomKeybinds(): CustomKeybind[] { + return [ + ...getRemoteWidgetKeybinds(), + { key: 'o', label: '(o)wner only when fork', action: TOGGLE_OWNER_ONLY_ACTION } + ]; + } + + supportsRawValue(): boolean { return false; } + supportsColors(_item: WidgetItem): boolean { return true; } +} \ No newline at end of file diff --git a/src/widgets/GitOriginRepo.ts b/src/widgets/GitOriginRepo.ts new file mode 100644 index 00000000..502eb3dd --- /dev/null +++ b/src/widgets/GitOriginRepo.ts @@ -0,0 +1,70 @@ +import type { RenderContext } from '../types/RenderContext'; +import type { Settings } from '../types/Settings'; +import type { + CustomKeybind, + Widget, + WidgetEditorDisplay, + WidgetItem +} from '../types/Widget'; +import { + buildRepoWebUrl, + getRemoteInfo +} from '../utils/git-remote'; +import { renderOsc8Link } from '../utils/hyperlink'; + +import { + getRemoteWidgetKeybinds, + getRemoteWidgetModifierText, + handleRemoteWidgetAction, + isHideNoRemoteEnabled, + isLinkToRepoEnabled +} from './shared/git-remote'; + +export class GitOriginRepoWidget implements Widget { + getDefaultColor(): string { return 'cyan'; } + getDescription(): string { return 'Shows the origin remote repository name'; } + getDisplayName(): string { return 'Git Origin Repo'; } + getCategory(): string { return 'Git'; } + + getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { + return { + displayText: this.getDisplayName(), + modifierText: getRemoteWidgetModifierText(item) + }; + } + + handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { + return handleRemoteWidgetAction(action, item); + } + + render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { + const hideWhenEmpty = isHideNoRemoteEnabled(item); + const linkEnabled = isLinkToRepoEnabled(item); + + if (context.isPreview) { + const text = 'repo'; + return linkEnabled ? renderOsc8Link('https://github.com/owner/repo', text) : text; + } + + const origin = getRemoteInfo('origin', context); + if (!origin) { + return hideWhenEmpty ? null : 'no remote'; + } + + const text = origin.repo; + + if (linkEnabled) { + const url = buildRepoWebUrl(origin); + return renderOsc8Link(url, text); + } + + return text; + } + + getCustomKeybinds(): CustomKeybind[] { + return getRemoteWidgetKeybinds(); + } + + supportsRawValue(): boolean { return false; } + supportsColors(_item: WidgetItem): boolean { return true; } +} \ No newline at end of file diff --git a/src/widgets/GitSha.ts b/src/widgets/GitSha.ts new file mode 100644 index 00000000..863265ed --- /dev/null +++ b/src/widgets/GitSha.ts @@ -0,0 +1,59 @@ +import type { RenderContext } from '../types/RenderContext'; +import type { Settings } from '../types/Settings'; +import type { + CustomKeybind, + Widget, + WidgetEditorDisplay, + WidgetItem +} from '../types/Widget'; +import { + getGitShortSha, + isInsideGitWorkTree +} from '../utils/git'; + +import { + getHideNoGitKeybinds, + getHideNoGitModifierText, + handleToggleNoGitAction, + isHideNoGitEnabled +} from './shared/git-no-git'; + +export class GitShaWidget implements Widget { + getDefaultColor(): string { return 'gray'; } + getDescription(): string { return 'Shows short commit hash (SHA)'; } + getDisplayName(): string { return 'Git SHA'; } + getCategory(): string { return 'Git'; } + + getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { + return { + displayText: this.getDisplayName(), + modifierText: getHideNoGitModifierText(item) + }; + } + + handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { + return handleToggleNoGitAction(action, item); + } + + render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { + const hideNoGit = isHideNoGitEnabled(item); + + if (context.isPreview) { + return 'a1b2c3d'; + } + + if (!isInsideGitWorkTree(context)) { + return hideNoGit ? null : '(no git)'; + } + + const sha = getGitShortSha(context); + return sha ?? (hideNoGit ? null : '(no commit)'); + } + + getCustomKeybinds(): CustomKeybind[] { + return getHideNoGitKeybinds(); + } + + supportsRawValue(): boolean { return false; } + supportsColors(_item: WidgetItem): boolean { return true; } +} \ No newline at end of file diff --git a/src/widgets/GitStaged.ts b/src/widgets/GitStaged.ts new file mode 100644 index 00000000..9d0146b5 --- /dev/null +++ b/src/widgets/GitStaged.ts @@ -0,0 +1,79 @@ +import type { RenderContext } from '../types/RenderContext'; +import type { Settings } from '../types/Settings'; +import type { + CustomKeybind, + Widget, + WidgetEditorDisplay, + WidgetItem +} from '../types/Widget'; +import { + getGitStatus, + isInsideGitWorkTree +} from '../utils/git'; + +import { makeModifierText } from './shared/editor-display'; +import { + getHideNoGitKeybinds, + getHideNoGitModifierText, + handleToggleNoGitAction, + isHideNoGitEnabled +} from './shared/git-no-git'; + +const DEFAULT_SYMBOL = '+'; + +export class GitStagedWidget implements Widget { + getDefaultColor(): string { return 'green'; } + getDescription(): string { return 'Shows + when there are staged changes'; } + getDisplayName(): string { return 'Git Staged'; } + getCategory(): string { return 'Git'; } + + getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { + const modifiers: string[] = []; + const noGitText = getHideNoGitModifierText(item); + if (noGitText) + modifiers.push('hide \'no git\''); + + return { + displayText: this.getDisplayName(), + modifierText: makeModifierText(modifiers) + }; + } + + handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { + return handleToggleNoGitAction(action, item); + } + + render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { + const hideNoGit = isHideNoGitEnabled(item); + + if (context.isPreview) { + return item.rawValue ? 'true' : (item.character ?? DEFAULT_SYMBOL); + } + + if (!isInsideGitWorkTree(context)) { + return hideNoGit ? null : '(no git)'; + } + + const status = getGitStatus(context); + + if (!status.staged) { + return null; + } + + return item.rawValue ? 'true' : (item.character ?? DEFAULT_SYMBOL); + } + + getCustomKeybinds(): CustomKeybind[] { + return getHideNoGitKeybinds(); + } + + getNumericValue(context: RenderContext, _item: WidgetItem): number | null { + if (!isInsideGitWorkTree(context)) + return null; + const status = getGitStatus(context); + return status.staged ? 1 : 0; + } + + supportsRawValue(): boolean { return true; } + supportsColors(_item: WidgetItem): boolean { return true; } +} \ No newline at end of file diff --git a/src/widgets/GitStatus.ts b/src/widgets/GitStatus.ts new file mode 100644 index 00000000..f9fe956c --- /dev/null +++ b/src/widgets/GitStatus.ts @@ -0,0 +1,83 @@ +import type { RenderContext } from '../types/RenderContext'; +import type { Settings } from '../types/Settings'; +import type { + CustomKeybind, + Widget, + WidgetEditorDisplay, + WidgetItem +} from '../types/Widget'; +import { + getGitStatus, + isInsideGitWorkTree +} from '../utils/git'; + +import { makeModifierText } from './shared/editor-display'; +import { + getHideNoGitKeybinds, + getHideNoGitModifierText, + handleToggleNoGitAction, + isHideNoGitEnabled +} from './shared/git-no-git'; + +export class GitStatusWidget implements Widget { + getDefaultColor(): string { return 'yellow'; } + getDescription(): string { return 'Shows git status indicators: + staged, * unstaged, ? untracked'; } + getDisplayName(): string { return 'Git Status'; } + getCategory(): string { return 'Git'; } + + getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { + const modifiers: string[] = []; + const noGitText = getHideNoGitModifierText(item); + if (noGitText) + modifiers.push('hide \'no git\''); + + return { + displayText: this.getDisplayName(), + modifierText: makeModifierText(modifiers) + }; + } + + handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { + return handleToggleNoGitAction(action, item); + } + + render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { + const hideNoGit = isHideNoGitEnabled(item); + + if (context.isPreview) { + return this.formatStatus(item, { staged: true, unstaged: true, untracked: false }); + } + + if (!isInsideGitWorkTree(context)) { + return hideNoGit ? null : '(no git)'; + } + + const status = getGitStatus(context); + + // Hide if clean + if (!status.staged && !status.unstaged && !status.untracked) { + return null; + } + + return this.formatStatus(item, status); + } + + private formatStatus(_item: WidgetItem, status: { staged: boolean; unstaged: boolean; untracked: boolean }): string { + const parts: string[] = []; + if (status.staged) + parts.push('+'); + if (status.unstaged) + parts.push('*'); + if (status.untracked) + parts.push('?'); + + return parts.join(''); + } + + getCustomKeybinds(): CustomKeybind[] { + return getHideNoGitKeybinds(); + } + + supportsRawValue(): boolean { return false; } + supportsColors(_item: WidgetItem): boolean { return true; } +} \ No newline at end of file diff --git a/src/widgets/GitUnstaged.ts b/src/widgets/GitUnstaged.ts new file mode 100644 index 00000000..fb1cf651 --- /dev/null +++ b/src/widgets/GitUnstaged.ts @@ -0,0 +1,79 @@ +import type { RenderContext } from '../types/RenderContext'; +import type { Settings } from '../types/Settings'; +import type { + CustomKeybind, + Widget, + WidgetEditorDisplay, + WidgetItem +} from '../types/Widget'; +import { + getGitStatus, + isInsideGitWorkTree +} from '../utils/git'; + +import { makeModifierText } from './shared/editor-display'; +import { + getHideNoGitKeybinds, + getHideNoGitModifierText, + handleToggleNoGitAction, + isHideNoGitEnabled +} from './shared/git-no-git'; + +const DEFAULT_SYMBOL = '*'; + +export class GitUnstagedWidget implements Widget { + getDefaultColor(): string { return 'yellow'; } + getDescription(): string { return 'Shows * when there are unstaged changes'; } + getDisplayName(): string { return 'Git Unstaged'; } + getCategory(): string { return 'Git'; } + + getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { + const modifiers: string[] = []; + const noGitText = getHideNoGitModifierText(item); + if (noGitText) + modifiers.push('hide \'no git\''); + + return { + displayText: this.getDisplayName(), + modifierText: makeModifierText(modifiers) + }; + } + + handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { + return handleToggleNoGitAction(action, item); + } + + render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { + const hideNoGit = isHideNoGitEnabled(item); + + if (context.isPreview) { + return item.rawValue ? 'true' : (item.character ?? DEFAULT_SYMBOL); + } + + if (!isInsideGitWorkTree(context)) { + return hideNoGit ? null : '(no git)'; + } + + const status = getGitStatus(context); + + if (!status.unstaged) { + return null; + } + + return item.rawValue ? 'true' : (item.character ?? DEFAULT_SYMBOL); + } + + getCustomKeybinds(): CustomKeybind[] { + return getHideNoGitKeybinds(); + } + + getNumericValue(context: RenderContext, _item: WidgetItem): number | null { + if (!isInsideGitWorkTree(context)) + return null; + const status = getGitStatus(context); + return status.unstaged ? 1 : 0; + } + + supportsRawValue(): boolean { return true; } + supportsColors(_item: WidgetItem): boolean { return true; } +} \ No newline at end of file diff --git a/src/widgets/GitUntracked.ts b/src/widgets/GitUntracked.ts new file mode 100644 index 00000000..40dc0b0a --- /dev/null +++ b/src/widgets/GitUntracked.ts @@ -0,0 +1,79 @@ +import type { RenderContext } from '../types/RenderContext'; +import type { Settings } from '../types/Settings'; +import type { + CustomKeybind, + Widget, + WidgetEditorDisplay, + WidgetItem +} from '../types/Widget'; +import { + getGitStatus, + isInsideGitWorkTree +} from '../utils/git'; + +import { makeModifierText } from './shared/editor-display'; +import { + getHideNoGitKeybinds, + getHideNoGitModifierText, + handleToggleNoGitAction, + isHideNoGitEnabled +} from './shared/git-no-git'; + +const DEFAULT_SYMBOL = '?'; + +export class GitUntrackedWidget implements Widget { + getDefaultColor(): string { return 'red'; } + getDescription(): string { return 'Shows ? when there are untracked files'; } + getDisplayName(): string { return 'Git Untracked'; } + getCategory(): string { return 'Git'; } + + getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { + const modifiers: string[] = []; + const noGitText = getHideNoGitModifierText(item); + if (noGitText) + modifiers.push('hide \'no git\''); + + return { + displayText: this.getDisplayName(), + modifierText: makeModifierText(modifiers) + }; + } + + handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { + return handleToggleNoGitAction(action, item); + } + + render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { + const hideNoGit = isHideNoGitEnabled(item); + + if (context.isPreview) { + return item.rawValue ? 'true' : (item.character ?? DEFAULT_SYMBOL); + } + + if (!isInsideGitWorkTree(context)) { + return hideNoGit ? null : '(no git)'; + } + + const status = getGitStatus(context); + + if (!status.untracked) { + return null; + } + + return item.rawValue ? 'true' : (item.character ?? DEFAULT_SYMBOL); + } + + getCustomKeybinds(): CustomKeybind[] { + return getHideNoGitKeybinds(); + } + + getNumericValue(context: RenderContext, _item: WidgetItem): number | null { + if (!isInsideGitWorkTree(context)) + return null; + const status = getGitStatus(context); + return status.untracked ? 1 : 0; + } + + supportsRawValue(): boolean { return true; } + supportsColors(_item: WidgetItem): boolean { return true; } +} \ No newline at end of file diff --git a/src/widgets/GitUpstreamOwner.ts b/src/widgets/GitUpstreamOwner.ts new file mode 100644 index 00000000..e206a6b7 --- /dev/null +++ b/src/widgets/GitUpstreamOwner.ts @@ -0,0 +1,70 @@ +import type { RenderContext } from '../types/RenderContext'; +import type { Settings } from '../types/Settings'; +import type { + CustomKeybind, + Widget, + WidgetEditorDisplay, + WidgetItem +} from '../types/Widget'; +import { + buildRepoWebUrl, + getRemoteInfo +} from '../utils/git-remote'; +import { renderOsc8Link } from '../utils/hyperlink'; + +import { + getRemoteWidgetKeybinds, + getRemoteWidgetModifierText, + handleRemoteWidgetAction, + isHideNoRemoteEnabled, + isLinkToRepoEnabled +} from './shared/git-remote'; + +export class GitUpstreamOwnerWidget implements Widget { + getDefaultColor(): string { return 'magenta'; } + getDescription(): string { return 'Shows the upstream remote owner/organization'; } + getDisplayName(): string { return 'Git Upstream Owner'; } + getCategory(): string { return 'Git'; } + + getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { + return { + displayText: this.getDisplayName(), + modifierText: getRemoteWidgetModifierText(item) + }; + } + + handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { + return handleRemoteWidgetAction(action, item); + } + + render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { + const hideWhenEmpty = isHideNoRemoteEnabled(item); + const linkEnabled = isLinkToRepoEnabled(item); + + if (context.isPreview) { + const text = 'upstream-owner'; + return linkEnabled ? renderOsc8Link('https://github.com/upstream-owner/repo', text) : text; + } + + const upstream = getRemoteInfo('upstream', context); + if (!upstream) { + return hideWhenEmpty ? null : 'no upstream'; + } + + const text = upstream.owner; + + if (linkEnabled) { + const url = buildRepoWebUrl(upstream); + return renderOsc8Link(url, text); + } + + return text; + } + + getCustomKeybinds(): CustomKeybind[] { + return getRemoteWidgetKeybinds(); + } + + supportsRawValue(): boolean { return false; } + supportsColors(_item: WidgetItem): boolean { return true; } +} \ No newline at end of file diff --git a/src/widgets/GitUpstreamOwnerRepo.ts b/src/widgets/GitUpstreamOwnerRepo.ts new file mode 100644 index 00000000..d42ece13 --- /dev/null +++ b/src/widgets/GitUpstreamOwnerRepo.ts @@ -0,0 +1,70 @@ +import type { RenderContext } from '../types/RenderContext'; +import type { Settings } from '../types/Settings'; +import type { + CustomKeybind, + Widget, + WidgetEditorDisplay, + WidgetItem +} from '../types/Widget'; +import { + buildRepoWebUrl, + getRemoteInfo +} from '../utils/git-remote'; +import { renderOsc8Link } from '../utils/hyperlink'; + +import { + getRemoteWidgetKeybinds, + getRemoteWidgetModifierText, + handleRemoteWidgetAction, + isHideNoRemoteEnabled, + isLinkToRepoEnabled +} from './shared/git-remote'; + +export class GitUpstreamOwnerRepoWidget implements Widget { + getDefaultColor(): string { return 'magenta'; } + getDescription(): string { return 'Shows the upstream remote as owner/repo'; } + getDisplayName(): string { return 'Git Upstream Owner/Repo'; } + getCategory(): string { return 'Git'; } + + getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { + return { + displayText: this.getDisplayName(), + modifierText: getRemoteWidgetModifierText(item) + }; + } + + handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { + return handleRemoteWidgetAction(action, item); + } + + render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { + const hideWhenEmpty = isHideNoRemoteEnabled(item); + const linkEnabled = isLinkToRepoEnabled(item); + + if (context.isPreview) { + const text = 'upstream-owner/upstream-repo'; + return linkEnabled ? renderOsc8Link('https://github.com/upstream-owner/upstream-repo', text) : text; + } + + const upstream = getRemoteInfo('upstream', context); + if (!upstream) { + return hideWhenEmpty ? null : 'no upstream'; + } + + const text = `${upstream.owner}/${upstream.repo}`; + + if (linkEnabled) { + const url = buildRepoWebUrl(upstream); + return renderOsc8Link(url, text); + } + + return text; + } + + getCustomKeybinds(): CustomKeybind[] { + return getRemoteWidgetKeybinds(); + } + + supportsRawValue(): boolean { return false; } + supportsColors(_item: WidgetItem): boolean { return true; } +} \ No newline at end of file diff --git a/src/widgets/GitUpstreamRepo.ts b/src/widgets/GitUpstreamRepo.ts new file mode 100644 index 00000000..a1faa8e5 --- /dev/null +++ b/src/widgets/GitUpstreamRepo.ts @@ -0,0 +1,70 @@ +import type { RenderContext } from '../types/RenderContext'; +import type { Settings } from '../types/Settings'; +import type { + CustomKeybind, + Widget, + WidgetEditorDisplay, + WidgetItem +} from '../types/Widget'; +import { + buildRepoWebUrl, + getRemoteInfo +} from '../utils/git-remote'; +import { renderOsc8Link } from '../utils/hyperlink'; + +import { + getRemoteWidgetKeybinds, + getRemoteWidgetModifierText, + handleRemoteWidgetAction, + isHideNoRemoteEnabled, + isLinkToRepoEnabled +} from './shared/git-remote'; + +export class GitUpstreamRepoWidget implements Widget { + getDefaultColor(): string { return 'magenta'; } + getDescription(): string { return 'Shows the upstream remote repository name'; } + getDisplayName(): string { return 'Git Upstream Repo'; } + getCategory(): string { return 'Git'; } + + getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { + return { + displayText: this.getDisplayName(), + modifierText: getRemoteWidgetModifierText(item) + }; + } + + handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { + return handleRemoteWidgetAction(action, item); + } + + render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { + const hideWhenEmpty = isHideNoRemoteEnabled(item); + const linkEnabled = isLinkToRepoEnabled(item); + + if (context.isPreview) { + const text = 'upstream-repo'; + return linkEnabled ? renderOsc8Link('https://github.com/upstream-owner/upstream-repo', text) : text; + } + + const upstream = getRemoteInfo('upstream', context); + if (!upstream) { + return hideWhenEmpty ? null : 'no upstream'; + } + + const text = upstream.repo; + + if (linkEnabled) { + const url = buildRepoWebUrl(upstream); + return renderOsc8Link(url, text); + } + + return text; + } + + getCustomKeybinds(): CustomKeybind[] { + return getRemoteWidgetKeybinds(); + } + + supportsRawValue(): boolean { return false; } + supportsColors(_item: WidgetItem): boolean { return true; } +} \ No newline at end of file diff --git a/src/widgets/WorktreeBranch.ts b/src/widgets/WorktreeBranch.ts new file mode 100644 index 00000000..0faacc5f --- /dev/null +++ b/src/widgets/WorktreeBranch.ts @@ -0,0 +1,30 @@ +import type { RenderContext } from '../types/RenderContext'; +import type { Settings } from '../types/Settings'; +import type { + Widget, + WidgetEditorDisplay, + WidgetItem +} from '../types/Widget'; + +export class WorktreeBranchWidget implements Widget { + getDefaultColor(): string { return 'yellow'; } + getDescription(): string { return 'Git branch name for the active worktree'; } + getDisplayName(): string { return 'Worktree Branch'; } + getCategory(): string { return 'Session'; } + + getEditorDisplay(_item: WidgetItem): WidgetEditorDisplay { + return { displayText: this.getDisplayName() }; + } + + render(_item: WidgetItem, context: RenderContext, _settings: Settings): string | null { + if (context.isPreview) { + return 'wt-my-feature'; + } + + const branch = context.data?.worktree?.branch; + return branch ?? null; + } + + supportsRawValue(): boolean { return false; } + supportsColors(_item: WidgetItem): boolean { return true; } +} \ No newline at end of file diff --git a/src/widgets/WorktreeMode.ts b/src/widgets/WorktreeMode.ts new file mode 100644 index 00000000..d074ecef --- /dev/null +++ b/src/widgets/WorktreeMode.ts @@ -0,0 +1,36 @@ +import type { RenderContext } from '../types/RenderContext'; +import type { Settings } from '../types/Settings'; +import type { + Widget, + WidgetEditorDisplay, + WidgetItem +} from '../types/Widget'; + +export class WorktreeModeWidget implements Widget { + getDefaultColor(): string { return 'yellow'; } + getDescription(): string { return 'Shows indicator when Claude Code is in worktree mode'; } + getDisplayName(): string { return 'Worktree Mode'; } + getCategory(): string { return 'Session'; } + + getEditorDisplay(_item: WidgetItem): WidgetEditorDisplay { + return { displayText: this.getDisplayName() }; + } + + render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { + const worktree = context.isPreview ? true : context.data?.worktree; + const isInWorktree = worktree !== undefined && worktree !== null; + + if (item.rawValue) { + return isInWorktree ? 'true' : 'false'; + } + + if (!isInWorktree) { + return null; + } + + return '⎇'; + } + + supportsRawValue(): boolean { return true; } + supportsColors(_item: WidgetItem): boolean { return true; } +} \ No newline at end of file diff --git a/src/widgets/WorktreeName.ts b/src/widgets/WorktreeName.ts new file mode 100644 index 00000000..b211cd19 --- /dev/null +++ b/src/widgets/WorktreeName.ts @@ -0,0 +1,30 @@ +import type { RenderContext } from '../types/RenderContext'; +import type { Settings } from '../types/Settings'; +import type { + Widget, + WidgetEditorDisplay, + WidgetItem +} from '../types/Widget'; + +export class WorktreeNameWidget implements Widget { + getDefaultColor(): string { return 'yellow'; } + getDescription(): string { return 'Name of the active worktree'; } + getDisplayName(): string { return 'Worktree Name'; } + getCategory(): string { return 'Session'; } + + getEditorDisplay(_item: WidgetItem): WidgetEditorDisplay { + return { displayText: this.getDisplayName() }; + } + + render(_item: WidgetItem, context: RenderContext, _settings: Settings): string | null { + if (context.isPreview) { + return 'my-feature'; + } + + const name = context.data?.worktree?.name; + return name ?? null; + } + + supportsRawValue(): boolean { return false; } + supportsColors(_item: WidgetItem): boolean { return true; } +} \ No newline at end of file diff --git a/src/widgets/WorktreeOriginalBranch.ts b/src/widgets/WorktreeOriginalBranch.ts new file mode 100644 index 00000000..30ac898e --- /dev/null +++ b/src/widgets/WorktreeOriginalBranch.ts @@ -0,0 +1,30 @@ +import type { RenderContext } from '../types/RenderContext'; +import type { Settings } from '../types/Settings'; +import type { + Widget, + WidgetEditorDisplay, + WidgetItem +} from '../types/Widget'; + +export class WorktreeOriginalBranchWidget implements Widget { + getDefaultColor(): string { return 'yellow'; } + getDescription(): string { return 'Git branch checked out before entering the worktree'; } + getDisplayName(): string { return 'Worktree Original Branch'; } + getCategory(): string { return 'Session'; } + + getEditorDisplay(_item: WidgetItem): WidgetEditorDisplay { + return { displayText: this.getDisplayName() }; + } + + render(_item: WidgetItem, context: RenderContext, _settings: Settings): string | null { + if (context.isPreview) { + return 'main'; + } + + const originalBranch = context.data?.worktree?.original_branch; + return originalBranch ?? null; + } + + supportsRawValue(): boolean { return false; } + supportsColors(_item: WidgetItem): boolean { return true; } +} \ No newline at end of file diff --git a/src/widgets/__tests__/GitBranch.test.ts b/src/widgets/__tests__/GitBranch.test.ts index 6ee9856b..20c0a7bd 100644 --- a/src/widgets/__tests__/GitBranch.test.ts +++ b/src/widgets/__tests__/GitBranch.test.ts @@ -10,6 +10,7 @@ import { import type { RenderContext } from '../../types/RenderContext'; import { DEFAULT_SETTINGS } from '../../types/Settings'; import type { WidgetItem } from '../../types/Widget'; +import { clearGitCache } from '../../utils/git'; import { renderOsc8Link } from '../../utils/hyperlink'; import { GitBranchWidget } from '../GitBranch'; @@ -53,6 +54,7 @@ function render(options: { describe('GitBranchWidget', () => { beforeEach(() => { vi.clearAllMocks(); + clearGitCache(); }); it('should render preview', () => { diff --git a/src/widgets/__tests__/GitChanges.test.ts b/src/widgets/__tests__/GitChanges.test.ts index d9161a64..ace47013 100644 --- a/src/widgets/__tests__/GitChanges.test.ts +++ b/src/widgets/__tests__/GitChanges.test.ts @@ -10,6 +10,7 @@ import { import type { RenderContext } from '../../types/RenderContext'; import { DEFAULT_SETTINGS } from '../../types/Settings'; import type { WidgetItem } from '../../types/Widget'; +import { clearGitCache } from '../../utils/git'; import { GitChangesWidget } from '../GitChanges'; vi.mock('child_process', () => ({ execSync: vi.fn() })); @@ -43,6 +44,7 @@ function render(options: { describe('GitChangesWidget', () => { beforeEach(() => { vi.clearAllMocks(); + clearGitCache(); }); it('should render preview', () => { diff --git a/src/widgets/__tests__/GitDeletions.test.ts b/src/widgets/__tests__/GitDeletions.test.ts index a7b71bd9..10d1cab5 100644 --- a/src/widgets/__tests__/GitDeletions.test.ts +++ b/src/widgets/__tests__/GitDeletions.test.ts @@ -10,6 +10,7 @@ import { import type { RenderContext } from '../../types/RenderContext'; import { DEFAULT_SETTINGS } from '../../types/Settings'; import type { WidgetItem } from '../../types/Widget'; +import { clearGitCache } from '../../utils/git'; import { GitDeletionsWidget } from '../GitDeletions'; vi.mock('child_process', () => ({ execSync: vi.fn() })); @@ -43,6 +44,7 @@ function render(options: { describe('GitDeletionsWidget', () => { beforeEach(() => { vi.clearAllMocks(); + clearGitCache(); }); it('should render preview', () => { diff --git a/src/widgets/__tests__/GitInsertions.test.ts b/src/widgets/__tests__/GitInsertions.test.ts index 519df176..19085eeb 100644 --- a/src/widgets/__tests__/GitInsertions.test.ts +++ b/src/widgets/__tests__/GitInsertions.test.ts @@ -10,6 +10,7 @@ import { import type { RenderContext } from '../../types/RenderContext'; import { DEFAULT_SETTINGS } from '../../types/Settings'; import type { WidgetItem } from '../../types/Widget'; +import { clearGitCache } from '../../utils/git'; import { GitInsertionsWidget } from '../GitInsertions'; vi.mock('child_process', () => ({ execSync: vi.fn() })); @@ -43,6 +44,7 @@ function render(options: { describe('GitInsertionsWidget', () => { beforeEach(() => { vi.clearAllMocks(); + clearGitCache(); }); it('should render preview', () => { diff --git a/src/widgets/__tests__/GitRootDir.test.ts b/src/widgets/__tests__/GitRootDir.test.ts index 2d819474..2e3bc7fa 100644 --- a/src/widgets/__tests__/GitRootDir.test.ts +++ b/src/widgets/__tests__/GitRootDir.test.ts @@ -10,6 +10,7 @@ import { import type { RenderContext } from '../../types/RenderContext'; import { DEFAULT_SETTINGS } from '../../types/Settings'; import type { WidgetItem } from '../../types/Widget'; +import { clearGitCache } from '../../utils/git'; import { buildIdeFileUrl, renderOsc8Link @@ -43,6 +44,7 @@ function render(options: { cwd?: string; hideNoGit?: boolean; isPreview?: boolea describe('GitRootDirWidget', () => { beforeEach(() => { vi.clearAllMocks(); + clearGitCache(); }); it('should render preview', () => { diff --git a/src/widgets/__tests__/GitStatus.test.ts b/src/widgets/__tests__/GitStatus.test.ts new file mode 100644 index 00000000..3906fa55 --- /dev/null +++ b/src/widgets/__tests__/GitStatus.test.ts @@ -0,0 +1,28 @@ +import { + describe, + expect, + it +} from 'vitest'; + +import type { RenderContext } from '../../types/RenderContext'; +import { DEFAULT_SETTINGS } from '../../types/Settings'; +import { GitStatusWidget } from '../GitStatus'; + +describe('GitStatusWidget', () => { + const widget = new GitStatusWidget(); + + it('shows combined status indicators in preview', () => { + const context: RenderContext = { isPreview: true }; + const item = { id: '1', type: 'git-status' }; + const result = widget.render(item, context, DEFAULT_SETTINGS); + + expect(result).toBe('+*'); + }); + + it('has correct metadata', () => { + expect(widget.getDefaultColor()).toBe('yellow'); + expect(widget.getDisplayName()).toBe('Git Status'); + expect(widget.getCategory()).toBe('Git'); + expect(widget.supportsRawValue()).toBe(false); + }); +}); \ No newline at end of file diff --git a/src/widgets/__tests__/GitWorktree.test.ts b/src/widgets/__tests__/GitWorktree.test.ts index 25a9c00b..e8c3f23e 100644 --- a/src/widgets/__tests__/GitWorktree.test.ts +++ b/src/widgets/__tests__/GitWorktree.test.ts @@ -11,6 +11,7 @@ import type { RenderContext, WidgetItem } from '../../types'; +import { clearGitCache } from '../../utils/git'; import { GitWorktreeWidget } from '../GitWorktree'; vi.mock('child_process', () => ({ execSync: vi.fn() })); @@ -46,6 +47,7 @@ function render(options: { describe('GitWorktreeWidget', () => { beforeEach(() => { vi.clearAllMocks(); + clearGitCache(); }); it('should render preview', () => { diff --git a/src/widgets/index.ts b/src/widgets/index.ts index 74d92bce..c57abed0 100644 --- a/src/widgets/index.ts +++ b/src/widgets/index.ts @@ -6,6 +6,20 @@ export { GitInsertionsWidget } from './GitInsertions'; export { GitDeletionsWidget } from './GitDeletions'; export { GitRootDirWidget } from './GitRootDir'; export { GitWorktreeWidget } from './GitWorktree'; +export { GitStatusWidget } from './GitStatus'; +export { GitStagedWidget } from './GitStaged'; +export { GitUnstagedWidget } from './GitUnstaged'; +export { GitUntrackedWidget } from './GitUntracked'; +export { GitAheadBehindWidget } from './GitAheadBehind'; +export { GitConflictsWidget } from './GitConflicts'; +export { GitShaWidget } from './GitSha'; +export { GitOriginOwnerWidget } from './GitOriginOwner'; +export { GitOriginRepoWidget } from './GitOriginRepo'; +export { GitOriginOwnerRepoWidget } from './GitOriginOwnerRepo'; +export { GitUpstreamOwnerWidget } from './GitUpstreamOwner'; +export { GitUpstreamRepoWidget } from './GitUpstreamRepo'; +export { GitUpstreamOwnerRepoWidget } from './GitUpstreamOwnerRepo'; +export { GitIsForkWidget } from './GitIsFork'; export { TokensInputWidget } from './TokensInput'; export { TokensOutputWidget } from './TokensOutput'; export { TokensCachedWidget } from './TokensCached'; @@ -18,6 +32,7 @@ export { SessionCostWidget } from './SessionCost'; export { TerminalWidthWidget } from './TerminalWidth'; export { VersionWidget } from './Version'; export { CustomTextWidget } from './CustomText'; +export { CustomSymbolWidget } from './CustomSymbol'; export { CustomCommandWidget } from './CustomCommand'; export { BlockTimerWidget } from './BlockTimer'; export { CurrentWorkingDirWidget } from './CurrentWorkingDir'; @@ -35,4 +50,8 @@ export { ContextBarWidget } from './ContextBar'; export { LinkWidget } from './Link'; export { SkillsWidget } from './Skills'; export { ThinkingEffortWidget } from './ThinkingEffort'; -export { VimModeWidget } from './VimMode'; \ No newline at end of file +export { VimModeWidget } from './VimMode'; +export { WorktreeModeWidget } from './WorktreeMode'; +export { WorktreeNameWidget } from './WorktreeName'; +export { WorktreeBranchWidget } from './WorktreeBranch'; +export { WorktreeOriginalBranchWidget } from './WorktreeOriginalBranch'; \ No newline at end of file diff --git a/src/widgets/shared/git-remote.ts b/src/widgets/shared/git-remote.ts new file mode 100644 index 00000000..32f5a53b --- /dev/null +++ b/src/widgets/shared/git-remote.ts @@ -0,0 +1,68 @@ +import type { + CustomKeybind, + WidgetItem +} from '../../types/Widget'; + +import { makeModifierText } from './editor-display'; +import { + isMetadataFlagEnabled, + toggleMetadataFlag +} from './metadata'; + +const HIDE_NO_REMOTE_KEY = 'hideNoRemote'; +const TOGGLE_NO_REMOTE_ACTION = 'toggle-no-remote'; + +const LINK_TO_REPO_KEY = 'linkToRepo'; +const TOGGLE_LINK_ACTION = 'toggle-link'; + +const HIDE_NO_REMOTE_KEYBIND: CustomKeybind = { + key: 'h', + label: '(h)ide when no remote', + action: TOGGLE_NO_REMOTE_ACTION +}; + +const LINK_TO_REPO_KEYBIND: CustomKeybind = { + key: 'l', + label: '(l)ink to repo', + action: TOGGLE_LINK_ACTION +}; + +export function isHideNoRemoteEnabled(item: WidgetItem): boolean { + return isMetadataFlagEnabled(item, HIDE_NO_REMOTE_KEY); +} + +export function isLinkToRepoEnabled(item: WidgetItem): boolean { + return isMetadataFlagEnabled(item, LINK_TO_REPO_KEY); +} + +export function getRemoteWidgetModifierText(item: WidgetItem): string | undefined { + const modifiers: string[] = []; + + if (isHideNoRemoteEnabled(item)) { + modifiers.push('hide when empty'); + } + if (isLinkToRepoEnabled(item)) { + modifiers.push('link'); + } + + return makeModifierText(modifiers); +} + +export function handleRemoteWidgetAction(action: string, item: WidgetItem): WidgetItem | null { + if (action === TOGGLE_NO_REMOTE_ACTION) { + return toggleMetadataFlag(item, HIDE_NO_REMOTE_KEY); + } + if (action === TOGGLE_LINK_ACTION) { + return toggleMetadataFlag(item, LINK_TO_REPO_KEY); + } + + return null; +} + +export function getRemoteWidgetKeybinds(): CustomKeybind[] { + return [HIDE_NO_REMOTE_KEYBIND, LINK_TO_REPO_KEYBIND]; +} + +export function getHideNoRemoteKeybinds(): CustomKeybind[] { + return [HIDE_NO_REMOTE_KEYBIND]; +} \ No newline at end of file From e794433ba9906e5b41ec6d84c6744018909ec8fd Mon Sep 17 00:00:00 2001 From: Mark O'Keefe Date: Mon, 23 Mar 2026 15:01:01 +1100 Subject: [PATCH 02/42] feat: add widget rules engine for conditional property overrides Implements conditional property override system for widgets (#38): - Rules execute top-to-bottom with optional stop flags - Numeric, string, boolean, and set operators - Cross-widget conditions (change one widget based on another's value) - Generic hide property for all widgets - TUI editors for rules and conditions - Renderer integration for rule-applied colors, bold, and hide Includes RulesEditor, ConditionEditor, ColorEditor TUI components, rules engine core with widget value extraction, and full test coverage. Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 172 ++++ src/tui/components/ColorEditor.tsx | 287 ++++++ src/tui/components/ColorMenu.tsx | 7 + src/tui/components/ConditionEditor.tsx | 574 +++++++++++ src/tui/components/ItemsEditor.tsx | 49 +- src/tui/components/RulesEditor.tsx | 694 +++++++++++++ src/tui/components/StatusLinePreview.tsx | 41 +- .../components/color-editor/input-handlers.ts | 264 +++++ .../__tests__/input-handlers.test.ts | 15 +- .../components/items-editor/input-handlers.ts | 170 ++-- src/types/Condition.ts | 165 +++ src/types/Widget.ts | 7 +- src/types/__tests__/Condition.test.ts | 107 ++ src/types/__tests__/Widget.test.ts | 87 ++ src/utils/__tests__/renderer-rules.test.ts | 234 +++++ src/utils/__tests__/rules-engine.test.ts | 940 ++++++++++++++++++ src/utils/__tests__/widget-values.test.ts | 338 +++++++ src/utils/input-guards.ts | 11 + src/utils/renderer.ts | 62 +- src/utils/rules-engine.ts | 238 +++++ src/utils/widget-properties.ts | 176 ++++ src/utils/widget-values.ts | 220 ++++ 22 files changed, 4757 insertions(+), 101 deletions(-) create mode 100644 src/tui/components/ColorEditor.tsx create mode 100644 src/tui/components/ConditionEditor.tsx create mode 100644 src/tui/components/RulesEditor.tsx create mode 100644 src/tui/components/color-editor/input-handlers.ts create mode 100644 src/types/Condition.ts create mode 100644 src/types/__tests__/Condition.test.ts create mode 100644 src/types/__tests__/Widget.test.ts create mode 100644 src/utils/__tests__/renderer-rules.test.ts create mode 100644 src/utils/__tests__/rules-engine.test.ts create mode 100644 src/utils/__tests__/widget-values.test.ts create mode 100644 src/utils/rules-engine.ts create mode 100644 src/utils/widget-properties.ts create mode 100644 src/utils/widget-values.ts diff --git a/README.md b/README.md index eb22c2da..f3d4cd22 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ - [Quick Start](#-quick-start) - [Windows Support](#-windows-support) - [Usage](#-usage) + - [Widget Rules](#-widget-rules) - [API Documentation](#-api-documentation) - [Development](#️-development) - [Contributing](#-contributing) @@ -46,6 +47,18 @@ ## 🆕 Recent Updates +### v2.3.0 - Widget Rules Engine + +- **📏 Conditional Widget Rules** - Define rules that dynamically change widget properties based on conditions + - Apply color, bold, hide, and other property overrides when conditions match + - Supports numeric operators (`>`, `>=`, `<`, `<=`, `=`, `≠`) + - Supports string operators (`contains`, `starts with`, `ends with`, and negations) + - Supports boolean operators (`is true`, `is false`) + - Supports set operators (`in`, `not in` for matching against lists) + - Cross-widget conditions: change one widget's appearance based on another widget's value + - Rules execute top-to-bottom with optional `stop` flag to halt evaluation + - Edit rules via the TUI: press `x` on any widget in the line editor + ### v2.2.0 - v2.2.6 - Speed, widgets, links, and reliability updates - **🚀 New Token Speed widgets** - Added three widgets: **Input Speed**, **Output Speed**, and **Total Speed**. @@ -549,6 +562,7 @@ Common controls in the line editor: - `d` delete selected widget - `r` toggle raw value (supported widgets) - `m` cycle merge mode (`off` → `merge` → `merge no padding`) +- `x` open rules editor (define conditional property overrides) Widget-specific shortcuts: - **Git widgets**: `h` toggle hide `no git` output @@ -562,6 +576,164 @@ Widget-specific shortcuts: --- +### 📏 Widget Rules + +Rules let you dynamically change widget properties based on conditions. For example, change the Context % widget to red when usage exceeds 75%, or highlight the Git Branch widget when on `main`. + +#### Opening the Rules Editor + +In the line editor, press `x` on any widget to open its rules editor. + +#### How Rules Work + +Rules follow an MS Office-style model: +- Rules are evaluated **top-to-bottom** in order +- Each matching rule's `apply` properties are merged onto the widget +- A rule with `stop: true` halts further evaluation when it matches +- The widget's base properties serve as defaults (no special "default rule" needed) + +#### Rules Schema + +```json +{ + "id": "context-1", + "type": "context-percentage", + "color": "white", + "rules": [ + { + "when": { "greaterThan": 75 }, + "apply": { "color": "red", "bold": true }, + "stop": true + }, + { + "when": { "greaterThan": 50 }, + "apply": { "color": "yellow" }, + "stop": true + } + ] +} +``` + +#### Available Operators + +| Category | Operators | Example | +|----------|-----------|---------| +| **Numeric** | `>`, `>=`, `<`, `<=`, `=`, `≠` | `{ "greaterThan": 75 }` | +| **String** | `contains`, `starts with`, `ends with` | `{ "contains": "feature/" }` | +| **String (negated)** | `does not contain`, `does not start with`, `does not end with` | `{ "contains": "main", "not": true }` | +| **Boolean** | `is true`, `is false` | `{ "isTrue": true }` | +| **Set** | `in`, `not in` | `{ "in": ["main", "master", "develop"] }` | + +#### Cross-Widget Conditions + +Reference another widget's value using the `widget` property: + +```json +{ + "when": { "widget": "git-branch", "in": ["main", "master"] }, + "apply": { "color": "cyan", "bold": true } +} +``` + +This rule changes the current widget's appearance based on the git branch name, even if the current widget is something else entirely (like Context %). + +#### Applyable Properties + +Rules can override any widget property: +- `color` - Foreground color +- `backgroundColor` - Background color +- `bold` - Bold text +- `hide` - Hide the widget entirely +- `rawValue` - Toggle raw value mode +- `merge` - Merge with adjacent widget +- `character` - Override display character +- Widget-specific metadata + +#### Rules Editor Keybinds + +The rules editor has two modes, toggled with `Tab`: + +**Property Mode:** +- `↑↓` - Navigate rules +- `←→` - Open condition editor +- `a` - Add new rule +- `d` - Delete selected rule +- `Enter` - Move mode (reorder rules) +- `s` - Toggle stop flag +- `h` - Toggle hide +- `r` - Toggle raw value +- `m` - Cycle merge mode +- `c` - Clear property overrides +- `Tab` - Switch to color mode + +**Color Mode:** +- `←→` - Cycle foreground color +- `↑↓` - Navigate rules +- `f` - Toggle foreground/background editing +- `b` - Toggle bold +- `h` - Enter hex color (truecolor terminals) +- `a` - Enter ANSI 256 color code +- `r` - Reset colors to base widget +- `Tab` - Switch to property mode + +#### Editing Conditions + +Press `←` or `→` in property mode to open the condition editor: + +- **←→** cycles between fields: Widget → Operator → Value +- **↑↓** opens pickers (widget picker or operator picker) +- **Enter** saves the condition +- **ESC** cancels + +The operator picker is organized by category (Numeric, String, Boolean, Set) — use `←→` to switch categories and `↑↓` to select an operator. + +#### Example: Traffic Light Context % + +```json +{ + "type": "context-percentage", + "color": "green", + "rules": [ + { "when": { "greaterThan": 80 }, "apply": { "color": "red", "bold": true }, "stop": true }, + { "when": { "greaterThan": 60 }, "apply": { "color": "yellow" }, "stop": true } + ] +} +``` +- 0-60%: Green (base color) +- 61-80%: Yellow +- 81-100%: Red + bold + +#### Example: Highlight Protected Branches + +```json +{ + "type": "git-branch", + "color": "white", + "rules": [ + { "when": { "in": ["main", "master", "production"] }, "apply": { "color": "cyan", "bold": true } }, + { "when": { "startsWith": "release/" }, "apply": { "color": "magenta" } } + ] +} +``` + +#### Example: Cross-Widget Alert + +```json +{ + "type": "model", + "color": "white", + "rules": [ + { + "when": { "widget": "context-percentage", "greaterThan": 90 }, + "apply": { "color": "red", "bold": true } + } + ] +} +``` +The Model widget turns red when context usage exceeds 90%. + +--- + ### 🔧 Custom Widgets #### Custom Text Widget diff --git a/src/tui/components/ColorEditor.tsx b/src/tui/components/ColorEditor.tsx new file mode 100644 index 00000000..d88a959f --- /dev/null +++ b/src/tui/components/ColorEditor.tsx @@ -0,0 +1,287 @@ +import { + Box, + Text, + useInput +} from 'ink'; +import React, { useState } from 'react'; + +import { getColorLevelString } from '../../types/ColorLevel'; +import type { Settings } from '../../types/Settings'; +import type { WidgetItem } from '../../types/Widget'; +import { + applyColors, + getAvailableBackgroundColorsForUI, + getAvailableColorsForUI +} from '../../utils/colors'; +import { shouldInsertInput } from '../../utils/input-guards'; +import { getWidget } from '../../utils/widgets'; + +export interface ColorEditorProps { + widget: WidgetItem; + settings: Settings; + onUpdate: (updatedWidget: WidgetItem) => void; + onReset?: () => void; // Optional: for resetting to base widget colors + onCancel: () => void; +} + +export const ColorEditor: React.FC = ({ + widget, + settings, + onUpdate, + onReset, + onCancel +}) => { + const [editingBackground, setEditingBackground] = useState(false); + const [hexInputMode, setHexInputMode] = useState(false); + const [hexInput, setHexInput] = useState(''); + const [ansi256InputMode, setAnsi256InputMode] = useState(false); + const [ansi256Input, setAnsi256Input] = useState(''); + + const widgetImpl = getWidget(widget.type); + const defaultColor = widgetImpl?.getDefaultColor() ?? 'white'; + + // Get available colors + const colorOptions = getAvailableColorsForUI(); + const colors = colorOptions.map(c => c.value); + const bgColorOptions = getAvailableBackgroundColorsForUI(); + const bgColors = bgColorOptions.map(c => c.value); + + // Cycle through colors + const cycleColor = (direction: 1 | -1) => { + const colorList = editingBackground ? bgColors : colors; + if (colorList.length === 0) { + return; + } + + const currentColor = editingBackground + ? (widget.backgroundColor ?? '') + : (widget.color ?? defaultColor); + + let currentIndex = colorList.indexOf(currentColor); + if (currentIndex === -1) { + currentIndex = 0; + } + + const nextIndex = direction > 0 + ? (currentIndex + 1) % colorList.length + : (currentIndex - 1 + colorList.length) % colorList.length; + const nextColor = colorList[nextIndex]; + + if (editingBackground) { + onUpdate({ + ...widget, + backgroundColor: nextColor === '' ? undefined : nextColor + }); + } else { + onUpdate({ + ...widget, + color: nextColor + }); + } + }; + + // Apply hex color + const applyHexColor = () => { + if (hexInput.length === 6) { + const hexColor = `hex:${hexInput}`; + if (editingBackground) { + onUpdate({ ...widget, backgroundColor: hexColor }); + } else { + onUpdate({ ...widget, color: hexColor }); + } + setHexInputMode(false); + setHexInput(''); + } + }; + + // Apply ANSI256 color + const applyAnsi256Color = () => { + const code = parseInt(ansi256Input, 10); + if (!isNaN(code) && code >= 0 && code <= 255) { + const ansiColor = `ansi256:${code}`; + if (editingBackground) { + onUpdate({ ...widget, backgroundColor: ansiColor }); + } else { + onUpdate({ ...widget, color: ansiColor }); + } + setAnsi256InputMode(false); + setAnsi256Input(''); + } + }; + + useInput((input, key) => { + // Handle hex input mode + if (hexInputMode) { + if (key.escape) { + setHexInputMode(false); + setHexInput(''); + } else if (key.return) { + applyHexColor(); + } else if (key.backspace || key.delete) { + setHexInput(hexInput.slice(0, -1)); + } else if (shouldInsertInput(input, key) && hexInput.length < 6) { + const upperInput = input.toUpperCase(); + if (/^[0-9A-F]$/.test(upperInput)) { + setHexInput(hexInput + upperInput); + } + } + return; + } + + // Handle ANSI256 input mode + if (ansi256InputMode) { + if (key.escape) { + setAnsi256InputMode(false); + setAnsi256Input(''); + } else if (key.return) { + applyAnsi256Color(); + } else if (key.backspace || key.delete) { + setAnsi256Input(ansi256Input.slice(0, -1)); + } else if (shouldInsertInput(input, key) && ansi256Input.length < 3) { + if (/^[0-9]$/.test(input)) { + const newInput = ansi256Input + input; + const code = parseInt(newInput, 10); + if (code <= 255) { + setAnsi256Input(newInput); + } + } + } + return; + } + + // Normal mode + if (key.escape) { + onCancel(); + } else if (input === 'h' && settings.colorLevel === 3) { + setHexInputMode(true); + setHexInput(''); + } else if (input === 'a' && settings.colorLevel === 2) { + setAnsi256InputMode(true); + setAnsi256Input(''); + } else if (input === 'f') { + setEditingBackground(!editingBackground); + } else if (input === 'b') { + onUpdate({ ...widget, bold: !widget.bold }); + } else if (input === 'r') { + if (onReset) { + onReset(); + } else { + // Default reset: remove color/backgroundColor/bold + const { color, backgroundColor, bold, ...rest } = widget; + void color; + void backgroundColor; + void bold; + onUpdate(rest); + } + } else if (key.leftArrow || key.rightArrow) { + cycleColor(key.rightArrow ? 1 : -1); + } + }); + + // Get current color for display + const currentColor = editingBackground + ? (widget.backgroundColor ?? '') + : (widget.color ?? defaultColor); + + const colorList = editingBackground ? bgColors : colors; + const colorIndex = colorList.indexOf(currentColor); + const colorNumber = colorIndex === -1 ? 'custom' : colorIndex + 1; + + // Format color display + let colorDisplay; + if (editingBackground) { + if (!currentColor) { + colorDisplay = (no background); + } else { + const displayName = currentColor.startsWith('ansi256:') + ? `ANSI ${currentColor.substring(8)}` + : currentColor.startsWith('hex:') + ? `#${currentColor.substring(4)}` + : bgColorOptions.find(c => c.value === currentColor)?.name ?? currentColor; + + const level = getColorLevelString(settings.colorLevel); + colorDisplay = {applyColors(` ${displayName} `, undefined, currentColor, false, level)}; + } + } else { + if (!currentColor) { + colorDisplay = (default); + } else { + const displayName = currentColor.startsWith('ansi256:') + ? `ANSI ${currentColor.substring(8)}` + : currentColor.startsWith('hex:') + ? `#${currentColor.substring(4)}` + : colorOptions.find(c => c.value === currentColor)?.name ?? currentColor; + + const level = getColorLevelString(settings.colorLevel); + colorDisplay = {applyColors(displayName, currentColor, undefined, false, level)}; + } + } + + // Get widget display name + const widgetName = widgetImpl?.getDisplayName() ?? widget.type; + + return ( + + + + Edit Colors: + {' '} + {widgetName} + {editingBackground && [BACKGROUND]} + + + + {hexInputMode ? ( + + Enter 6-digit hex color code (without #): + + # + {hexInput} + {hexInput.length < 6 ? '_'.repeat(6 - hexInput.length) : ''} + + Press Enter when done, ESC to cancel + + ) : ansi256InputMode ? ( + + Enter ANSI 256 color code (0-255): + + {ansi256Input} + + {ansi256Input.length === 0 ? '___' : ansi256Input.length === 1 ? '__' : ansi256Input.length === 2 ? '_' : ''} + + + Press Enter when done, ESC to cancel + + ) : ( + <> + + + Current + {' '} + {editingBackground ? 'background' : 'foreground'} + {' '} + ( + {colorNumber === 'custom' ? 'custom' : `${colorNumber}/${colorList.length}`} + ): + {' '} + {colorDisplay} + {widget.bold && [BOLD]} + + + + + + ←→ cycle + {' '} + {editingBackground ? 'background' : 'foreground'} + , (f) bg/fg, (b)old, + {settings.colorLevel === 3 ? ' (h)ex,' : settings.colorLevel === 2 ? ' (a)nsi256,' : ''} + {' '} + (r)eset, ESC cancel + + + + )} + + ); +}; \ No newline at end of file diff --git a/src/tui/components/ColorMenu.tsx b/src/tui/components/ColorMenu.tsx index 92178574..8bc07ad5 100644 --- a/src/tui/components/ColorMenu.tsx +++ b/src/tui/components/ColorMenu.tsx @@ -27,6 +27,13 @@ import { toggleWidgetBold } from './color-menu/mutations'; +export function getColorsWithHidden(): { name: string; value: string }[] { + return [ + { name: 'Hidden', value: 'hidden' }, + ...getAvailableColorsForUI() + ]; +} + export interface ColorMenuProps { widgets: WidgetItem[]; lineIndex?: number; diff --git a/src/tui/components/ConditionEditor.tsx b/src/tui/components/ConditionEditor.tsx new file mode 100644 index 00000000..6957db39 --- /dev/null +++ b/src/tui/components/ConditionEditor.tsx @@ -0,0 +1,574 @@ +import { + Box, + Text, + useInput +} from 'ink'; +import React, { useState } from 'react'; + +import { + BOOLEAN_OPERATORS, + DISPLAY_OPERATOR_CONFIG, + DISPLAY_OPERATOR_LABELS, + NUMERIC_OPERATORS, + OPERATOR_LABELS, + SET_OPERATORS, + STRING_OPERATORS, + getConditionNot, + getConditionOperator, + getConditionValue, + getConditionWidget, + getDisplayOperator, + isBooleanOperator, + isNumericOperator, + isSetOperator, + isStringOperator, + type DisplayOperator, + type Operator +} from '../../types/Condition'; +import type { Settings } from '../../types/Settings'; +import { + filterWidgetCatalog, + getWidget, + getWidgetCatalog, + getWidgetCatalogCategories +} from '../../utils/widgets'; + +import { + handlePickerInputMode, + normalizePickerState, + type WidgetPickerState +} from './items-editor/input-handlers'; + +export interface ConditionEditorProps { + widgetType: string; // For display name + condition: Record; + settings: Settings; // For widget catalog + onSave: (condition: Record) => void; + onCancel: () => void; +} + +export const ConditionEditor: React.FC = ({ + widgetType, + condition, + settings, + onSave, + onCancel +}) => { + const initialOp = getConditionOperator(condition) ?? 'greaterThan'; + const initialValue = getConditionValue(condition); + const initialWidget = getConditionWidget(condition); + const initialNot = getConditionNot(condition); + + const [operator, setOperator] = useState(initialOp); + const [valueInput, setValueInput] = useState(() => { + if (initialValue === null) { + return '50'; // Default numeric value + } + if (Array.isArray(initialValue)) { + return initialValue.map(v => String(v)).join(', '); + } + return String(initialValue); + }); + const [selectedWidget, setSelectedWidget] = useState(initialWidget); + const [notFlag, setNotFlag] = useState(initialNot); + const [mode, setMode] = useState<'widget' | 'operator' | 'value'>('widget'); + const [widgetPicker, setWidgetPicker] = useState(null); + const [operatorPicker, setOperatorPicker] = useState<{ + selectedIndex: number; + category: 'Numeric' | 'String' | 'Boolean' | 'Set'; + selectedItem: Operator | DisplayOperator; + } | null>(null); + + const widgetCatalog = getWidgetCatalog(settings); + const widgetCategories = ['All', ...getWidgetCatalogCategories(widgetCatalog)]; + + const openWidgetPicker = () => { + if (widgetCatalog.length === 0) { + return; + } + + setWidgetPicker(normalizePickerState({ + action: 'change', + level: 'category', + selectedCategory: 'All', + categoryQuery: '', + widgetQuery: '', + selectedType: selectedWidget === 'self' ? null : selectedWidget + }, widgetCatalog, widgetCategories)); + }; + + const applyWidgetPickerSelection = (selectedType: string) => { + setSelectedWidget(selectedType); + setWidgetPicker(null); + }; + + // Operator picker helpers + const getOperatorCategory = (op: Operator | DisplayOperator): 'Numeric' | 'String' | 'Boolean' | 'Set' => { + // Check if it's a display operator + if (op in DISPLAY_OPERATOR_CONFIG) { + const config = DISPLAY_OPERATOR_CONFIG[op as DisplayOperator]; + return getOperatorCategory(config.operator); + } + + // Base operators + if (isNumericOperator(op as Operator)) + return 'Numeric'; + if (isStringOperator(op as Operator)) + return 'String'; + if (isBooleanOperator(op as Operator)) + return 'Boolean'; + return 'Set'; + }; + + const getOperatorsInCategory = (category: 'Numeric' | 'String' | 'Boolean' | 'Set'): (Operator | DisplayOperator)[] => { + switch (category) { + case 'Numeric': + return [...NUMERIC_OPERATORS, 'notEquals']; + case 'String': + return [...STRING_OPERATORS, 'notContains', 'notStartsWith', 'notEndsWith']; + case 'Boolean': + return [...BOOLEAN_OPERATORS, 'isFalse']; + case 'Set': + return SET_OPERATORS; + } + }; + + const openOperatorPicker = () => { + // Check if current condition matches a display operator + const displayOp = getDisplayOperator(condition); + const currentOp = displayOp ?? operator; + + const currentCategory = getOperatorCategory(currentOp); + const operatorsInCategory = getOperatorsInCategory(currentCategory); + const selectedIndex = operatorsInCategory.indexOf(currentOp); + + setOperatorPicker({ + selectedIndex: Math.max(0, selectedIndex), + category: currentCategory, + selectedItem: currentOp + }); + }; + + const applyOperatorPickerSelection = (selectedOp: Operator | DisplayOperator) => { + // Check if it's a display operator + if (selectedOp in DISPLAY_OPERATOR_CONFIG) { + const config = DISPLAY_OPERATOR_CONFIG[selectedOp as DisplayOperator]; + setOperator(config.operator); + + if (config.not !== undefined) { + setNotFlag(config.not); + } + + if (config.value !== undefined) { + // Special case: isFalse uses isTrue with value false + setValueInput(String(config.value)); + } + } else { + // Base operator + setOperator(selectedOp as Operator); + setNotFlag(false); + } + + setOperatorPicker(null); + + // Update value input based on new operator type (for base operators) + const baseOp = selectedOp in DISPLAY_OPERATOR_CONFIG + ? DISPLAY_OPERATOR_CONFIG[selectedOp as DisplayOperator].operator + : selectedOp as Operator; + + if (isBooleanOperator(baseOp)) { + if (!(selectedOp in DISPLAY_OPERATOR_CONFIG && DISPLAY_OPERATOR_CONFIG[selectedOp as DisplayOperator].value !== undefined)) { + setValueInput('true'); + } + } else if (isSetOperator(baseOp)) { + setValueInput(''); + } else if (isStringOperator(baseOp)) { + setValueInput(''); + } + // Keep numeric value as-is for numeric operators + }; + + useInput((input, key) => { + // Handle widget picker input + if (widgetPicker) { + handlePickerInputMode({ + input, + key, + widgetPicker, + setWidgetPicker, + widgetCatalog, + widgetCategories, + applyWidgetPickerSelection + }); + return; + } + + // Handle operator picker input + if (operatorPicker) { + const categories: ('Numeric' | 'String' | 'Boolean' | 'Set')[] = ['Numeric', 'String', 'Boolean', 'Set']; + const operatorsInCategory = getOperatorsInCategory(operatorPicker.category); + + if (key.escape) { + setOperatorPicker(null); + } else if (key.leftArrow || key.rightArrow) { + // Switch category + const currentCategoryIndex = categories.indexOf(operatorPicker.category); + const nextCategoryIndex = key.rightArrow + ? (currentCategoryIndex + 1) % categories.length + : (currentCategoryIndex - 1 + categories.length) % categories.length; + const newCategory = categories[nextCategoryIndex] ?? categories[0]; + if (!newCategory) { + setOperatorPicker(null); + return; + } + const newOps = getOperatorsInCategory(newCategory); + const firstOperator = newOps[0]; + if (!firstOperator) { + setOperatorPicker(null); + return; + } + + setOperatorPicker({ + category: newCategory, + selectedIndex: 0, + selectedItem: firstOperator + }); + } else if (key.upArrow) { + const newIndex = Math.max(0, operatorPicker.selectedIndex - 1); + const selectedItem = operatorsInCategory[newIndex]; + if (!selectedItem) { + return; + } + setOperatorPicker({ + ...operatorPicker, + selectedIndex: newIndex, + selectedItem + }); + } else if (key.downArrow) { + const newIndex = Math.min(operatorsInCategory.length - 1, operatorPicker.selectedIndex + 1); + const selectedItem = operatorsInCategory[newIndex]; + if (!selectedItem) { + return; + } + setOperatorPicker({ + ...operatorPicker, + selectedIndex: newIndex, + selectedItem + }); + } else if (key.return) { + applyOperatorPickerSelection(operatorPicker.selectedItem); + } + return; + } + + if (key.escape) { + onCancel(); + } else if (key.return) { + // Save condition - parse value based on operator type + const newCondition: Record = {}; + + // Only include widget if it's not 'self' + if (selectedWidget !== 'self') { + newCondition.widget = selectedWidget; + } + + // Include not flag if true + if (notFlag) { + newCondition.not = true; + } + + // Parse and validate value based on operator type + if (isBooleanOperator(operator)) { + const boolValue = valueInput.toLowerCase() === 'true'; + newCondition[operator] = boolValue; + onSave(newCondition); + } else if (isSetOperator(operator)) { + // Parse comma-separated values + const values = valueInput + .split(',') + .map(v => v.trim()) + .filter(v => v.length > 0) + .map((v) => { + // Try to parse as number, otherwise keep as string + const num = Number(v); + return isNaN(num) ? v : num; + }); + + if (values.length > 0) { + newCondition[operator] = values; + onSave(newCondition); + } + // Don't save if empty + } else if (isStringOperator(operator)) { + // String value - no parsing needed + if (valueInput.trim().length > 0) { + newCondition[operator] = valueInput.trim(); + onSave(newCondition); + } + // Don't save if empty + } else { + // Numeric operator + const numValue = Number(valueInput); + if (!isNaN(numValue)) { + newCondition[operator] = numValue; + onSave(newCondition); + } + // Don't save if invalid number + } + } else if (key.leftArrow) { + // Navigate fields left (cycles): value → operator → widget → value + if (mode === 'value') { + setMode('operator'); + } else if (mode === 'operator') { + setMode('widget'); + } else { + setMode('value'); // Wrap around + } + } else if (key.rightArrow) { + // Navigate fields right (cycles): widget → operator → value → widget + if (mode === 'widget') { + setMode('operator'); + } else if (mode === 'operator') { + setMode('value'); + } else { + setMode('widget'); // Wrap around + } + } else if (key.upArrow || key.downArrow) { + // Open pickers with up/down + if (mode === 'widget') { + openWidgetPicker(); + } else if (mode === 'operator') { + openOperatorPicker(); + } + // In value mode, up/down does nothing (no picker) + } else if (mode === 'value') { + if (key.backspace || key.delete) { + setValueInput(valueInput.slice(0, -1)); + } else if (input) { + // Allow different input based on operator type + if (isBooleanOperator(operator)) { + // Toggle true/false + if (input === 't' || input === 'f') { + setValueInput(input === 't' ? 'true' : 'false'); + } + } else { + // For string, numeric, and set operators, allow any character + setValueInput(valueInput + input); + } + } + } + }); + + // Check if current state matches a display operator + const currentCondition = { + [operator]: valueInput, + ...(notFlag ? { not: true } : {}), + ...(selectedWidget !== 'self' ? { widget: selectedWidget } : {}) + }; + const displayOp = getDisplayOperator(currentCondition); + const opLabel = displayOp ? DISPLAY_OPERATOR_LABELS[displayOp] : OPERATOR_LABELS[operator]; + + // Validate value based on operator type + const isValid = (() => { + if (isBooleanOperator(operator)) { + return valueInput.toLowerCase() === 'true' || valueInput.toLowerCase() === 'false'; + } + if (isSetOperator(operator)) { + return valueInput.trim().length > 0; + } + if (isStringOperator(operator)) { + return valueInput.trim().length > 0; + } + // Numeric operator + return !isNaN(Number(valueInput)); + })(); + + // Get widget display name + const getWidgetDisplayName = (widgetType: string): string => { + if (widgetType === 'self') { + return 'This widget (self)'; + } + const widgetImpl = getWidget(widgetType); + return widgetImpl ? widgetImpl.getDisplayName() : widgetType; + }; + + const widgetLabel = getWidgetDisplayName(selectedWidget); + + // Render operator picker if open + if (operatorPicker) { + const categories: ('Numeric' | 'String' | 'Boolean' | 'Set')[] = ['Numeric', 'String', 'Boolean', 'Set']; + const operatorsInCategory = getOperatorsInCategory(operatorPicker.category); + + return ( + + + Select Operator + + + {/* Category navigation bar */} + + + {categories.map((cat, idx) => { + const isActive = cat === operatorPicker.category; + return ( + + {idx > 0 && } + + {isActive ? `[${cat}]` : cat} + + + ); + })} + + + + ↑↓ select, ←→ switch category, Enter apply, ESC cancel + + + {operatorsInCategory.map((op, idx) => { + const isSelected = idx === operatorPicker.selectedIndex; + // Get label from either base operators or display operators + const label = (op in DISPLAY_OPERATOR_LABELS) + ? DISPLAY_OPERATOR_LABELS[op as DisplayOperator] + : OPERATOR_LABELS[op as Operator]; + + return ( + + {isSelected ? '▶ ' : ' '} + {label} + + ); + })} + + + ); + } + + // Render widget picker if open + if (widgetPicker) { + const selectedPickerCategory = widgetPicker.selectedCategory ?? (widgetCategories[0] ?? 'All'); + const pickerCategories = widgetCategories.filter(c => c.toLowerCase().includes(widgetPicker.categoryQuery.toLowerCase()) + ); + const topLevelSearchEntries = widgetPicker.level === 'category' && widgetPicker.categoryQuery.trim().length > 0 + ? filterWidgetCatalog(widgetCatalog, 'All', widgetPicker.categoryQuery) + : []; + const selectedTopLevelSearchEntry = topLevelSearchEntries.find(entry => entry.type === widgetPicker.selectedType) ?? null; + const pickerEntries = filterWidgetCatalog(widgetCatalog, selectedPickerCategory, widgetPicker.widgetQuery); + const selectedPickerEntry = pickerEntries.find(entry => entry.type === widgetPicker.selectedType) ?? null; + + return ( + + + Select Widget to Reference + + + {widgetPicker.level === 'category' ? ( + <> + {widgetPicker.categoryQuery.trim().length > 0 ? ( + ↑↓ select widget match, Enter apply, ESC clear/cancel + ) : ( + ↑↓ select category, Enter drill in, type to search + )} + + Search: + {widgetPicker.categoryQuery || '(none)'} + + + ) : ( + <> + ↑↓ select widget, Enter apply, ESC back, type to search + + + Category: + {selectedPickerCategory} + + + + + Search: + {' '} + + {widgetPicker.widgetQuery || '(none)'} + + + )} + + + {widgetPicker.level === 'category' ? ( + widgetPicker.categoryQuery.trim().length > 0 ? ( + topLevelSearchEntries.length === 0 ? ( + No widgets match the search. + ) : ( + topLevelSearchEntries.map(entry => ( + + {selectedTopLevelSearchEntry?.type === entry.type ? '▶ ' : ' '} + {entry.displayName} + + )) + ) + ) : ( + pickerCategories.map(cat => ( + + {cat === selectedPickerCategory ? '▶ ' : ' '} + {cat} + + )) + ) + ) : ( + pickerEntries.length === 0 ? ( + No widgets in this category match the search. + ) : ( + pickerEntries.map(entry => ( + + {selectedPickerEntry?.type === entry.type ? '▶ ' : ' '} + {entry.displayName} + + )) + ) + )} + + + ); + } + + return ( + + + Edit Condition + + + + when + + {widgetLabel} + + + + {opLabel} + + {!isBooleanOperator(operator) && ( + <> + + + {isStringOperator(operator) && `"${valueInput || '(empty)'}"`} + {isSetOperator(operator) && `[${valueInput || '(empty)'}]`} + {!isStringOperator(operator) && !isSetOperator(operator) && (valueInput || '(empty)')} + + + )} + {!isValid && (invalid)} + + + + + {mode === 'widget' && '↑↓ open widget picker, ←→ switch field, Enter save, ESC cancel'} + {mode === 'operator' && '↑↓ open operator picker, ←→ switch field, Enter save, ESC cancel'} + {mode === 'value' && isBooleanOperator(operator) && 't/f for true/false, ←→ switch field, Enter save, ESC cancel'} + {mode === 'value' && isSetOperator(operator) && 'type comma-separated values, ←→ switch field, Enter save, ESC cancel'} + {mode === 'value' && isStringOperator(operator) && 'type text, ←→ switch field, Enter save, ESC cancel'} + {mode === 'value' && !isBooleanOperator(operator) && !isSetOperator(operator) && !isStringOperator(operator) && 'type number, ←→ switch field, Enter save, ESC cancel'} + + + + ); +}; \ No newline at end of file diff --git a/src/tui/components/ItemsEditor.tsx b/src/tui/components/ItemsEditor.tsx index 5a564b13..2571495f 100644 --- a/src/tui/components/ItemsEditor.tsx +++ b/src/tui/components/ItemsEditor.tsx @@ -23,6 +23,7 @@ import { } from '../../utils/widgets'; import { ConfirmDialog } from './ConfirmDialog'; +import { RulesEditor } from './RulesEditor'; import { handleMoveInputMode, handleNormalInputMode, @@ -47,6 +48,7 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB const [customEditorWidget, setCustomEditorWidget] = useState(null); const [widgetPicker, setWidgetPicker] = useState(null); const [showClearConfirm, setShowClearConfirm] = useState(false); + const [rulesEditorWidget, setRulesEditorWidget] = useState(null); const separatorChars = ['|', '-', ',', ' ']; const widgetCatalog = getWidgetCatalog(settings); @@ -157,6 +159,11 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB return; } + // Skip input if rules editor is active + if (rulesEditorWidget) { + return; + } + // Skip input handling when clear confirmation is active - let ConfirmDialog handle it if (showClearConfirm) { return; @@ -200,7 +207,8 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB setShowClearConfirm, openWidgetPicker, getCustomKeybindsForWidget, - setCustomEditorWidget + setCustomEditorWidget, + setRulesEditorWidget }); }); @@ -287,6 +295,9 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB if (canMerge) { helpText += ', (m)erge'; } + if (!isSeparator && !isFlexSeparator && hasWidgets) { + helpText += ', (x) exceptions'; + } helpText += ', ESC back'; // Build custom keybinds text @@ -307,6 +318,23 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB }); } + if (rulesEditorWidget) { + return ( + { + // Update widget in widgets array + const newWidgets = widgets.map(w => w.id === updatedWidget.id ? updatedWidget : w + ); + onUpdate(newWidgets); // This triggers preview update! + setRulesEditorWidget(updatedWidget); // Keep editor in sync + }} + onBack={() => { setRulesEditorWidget(null); }} + /> + ); + } + if (showClearConfirm) { return ( @@ -526,9 +554,28 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB {modifierText} )} + {widget.type !== 'separator' && widget.type !== 'flex-separator' && ( + + {' '} + [ + {widget.color ?? 'default'} + ] + + )} {supportsRawValue && widget.rawValue && (raw value)} {widget.merge === true && (merged→)} {widget.merge === 'no-padding' && (merged-no-pad→)} + {widget.rules && widget.rules.length > 0 && ( + + {' '} + ( + {widget.rules.length} + {' '} + rule + {widget.rules.length === 1 ? '' : 's'} + ) + + )} ); })} diff --git a/src/tui/components/RulesEditor.tsx b/src/tui/components/RulesEditor.tsx new file mode 100644 index 00000000..d64b7d33 --- /dev/null +++ b/src/tui/components/RulesEditor.tsx @@ -0,0 +1,694 @@ +import { + Box, + Text, + useInput +} from 'ink'; +import React, { useState } from 'react'; + +import { getColorLevelString } from '../../types/ColorLevel'; +import { + DISPLAY_OPERATOR_LABELS, + OPERATOR_LABELS, + getConditionNot, + getConditionOperator, + getConditionValue, + getConditionWidget, + getDisplayOperator, + isBooleanOperator, + isSetOperator, + isStringOperator +} from '../../types/Condition'; +import type { Settings } from '../../types/Settings'; +import type { WidgetItem } from '../../types/Widget'; +import { applyColors } from '../../utils/colors'; +import { + extractWidgetOverrides, + mergeWidgetWithRuleApply +} from '../../utils/widget-properties'; +import { getWidget } from '../../utils/widgets'; + +import { ConditionEditor } from './ConditionEditor'; +import { + getCurrentColorInfo, + handleColorInput, + type ColorEditorState +} from './color-editor/input-handlers'; +import { handleWidgetPropertyInput } from './items-editor/input-handlers'; + +export interface RulesEditorProps { + widget: WidgetItem; + settings: Settings; + onUpdate: (updatedWidget: WidgetItem) => void; + onBack: () => void; +} + +type RulesEditorMode = 'color' | 'property'; + +export const RulesEditor: React.FC = ({ widget, settings, onUpdate, onBack }) => { + const [selectedIndex, setSelectedIndex] = useState(0); + const [moveMode, setMoveMode] = useState(false); + const [conditionEditorIndex, setConditionEditorIndex] = useState(null); + const [editorMode, setEditorMode] = useState('property'); + const [colorEditorState, setColorEditorState] = useState({ + editingBackground: false, + hexInputMode: false, + hexInput: '', + ansi256InputMode: false, + ansi256Input: '' + }); + const [customEditorWidget, setCustomEditorWidget] = useState<{ widget: WidgetItem; impl: ReturnType; action?: string } | null>(null); + const rules = widget.rules ?? []; + + // Add new rule with placeholder values + const addRule = () => { + const newRule = { + when: { greaterThan: 50 }, // Placeholder - Phase 4 will make this editable + apply: {}, // Empty overrides - Phase 7 will make this editable + stop: false + }; + + const newRules = [...rules, newRule]; + const updatedWidget = { ...widget, rules: newRules }; + onUpdate(updatedWidget); + setSelectedIndex(newRules.length - 1); // Select newly added rule + }; + + // Delete selected rule + const deleteRule = () => { + if (rules.length === 0) { + return; + } + + const newRules = rules.filter((_, i) => i !== selectedIndex); + const updatedWidget = { ...widget, rules: newRules }; + onUpdate(updatedWidget); + + // Adjust selection after delete (same pattern as ItemsEditor) + if (selectedIndex >= newRules.length && selectedIndex > 0) { + setSelectedIndex(selectedIndex - 1); + } + }; + + // Handle custom editor completion + const handleEditorComplete = (updatedWidget: WidgetItem) => { + const rule = rules[selectedIndex]; + if (!rule) { + setCustomEditorWidget(null); + return; + } + + // Extract what changed compared to base widget + const newApply = extractWidgetOverrides(updatedWidget, widget, rule.apply); + const newRules = [...rules]; + newRules[selectedIndex] = { + ...rule, + apply: newApply + }; + onUpdate({ ...widget, rules: newRules }); + setCustomEditorWidget(null); + }; + + // Handle custom editor cancellation + const handleEditorCancel = () => { + setCustomEditorWidget(null); + }; + + // Handle color mode input using shared handler + const handleColorModeInput = (input: string, key: { leftArrow?: boolean; rightArrow?: boolean; escape?: boolean; return?: boolean; backspace?: boolean; delete?: boolean; upArrow?: boolean; downArrow?: boolean }) => { + const rule = rules[selectedIndex]; + if (!rule) { + return; + } + + // Create temp widget by merging base + apply + const tempWidget = mergeWidgetWithRuleApply(widget, rule.apply); + + // Use shared color input handler + handleColorInput({ + input, + key, + widget: tempWidget, + settings, + state: colorEditorState, + setState: setColorEditorState, + onUpdate: (updatedWidget) => { + // Extract what changed compared to base widget + const newApply = extractWidgetOverrides(updatedWidget, widget, rule.apply); + const newRules = [...rules]; + newRules[selectedIndex] = { + ...rule, + apply: newApply + }; + onUpdate({ ...widget, rules: newRules }); + }, + onReset: () => { + // Reset colors to base widget (remove color/backgroundColor/bold from apply) + const resetWidget = { + ...tempWidget, + color: widget.color, + backgroundColor: widget.backgroundColor, + bold: widget.bold + }; + const newApply = extractWidgetOverrides(resetWidget, widget, rule.apply); + const newRules = [...rules]; + newRules[selectedIndex] = { + ...rule, + apply: newApply + }; + onUpdate({ ...widget, rules: newRules }); + } + }); + }; + + // Handle property mode input + const handlePropertyModeInput = (input: string, key: { ctrl?: boolean; meta?: boolean }) => { + const rule = rules[selectedIndex]; + if (!rule) { + return; + } + + // Handle rule-specific properties + if (input === 's') { + // Toggle stop flag + const newRules = [...rules]; + newRules[selectedIndex] = { + ...rule, + stop: !rule.stop + }; + onUpdate({ ...widget, rules: newRules }); + return; + } + + if (input === 'h') { + // Toggle hide flag + const tempWidget = mergeWidgetWithRuleApply(widget, rule.apply); + const updatedWidget = { ...tempWidget, hide: !tempWidget.hide }; + + // Extract diffs and update + const newApply = extractWidgetOverrides(updatedWidget, widget, rule.apply); + const newRules = [...rules]; + newRules[selectedIndex] = { + ...rule, + apply: newApply + }; + onUpdate({ ...widget, rules: newRules }); + return; + } + + if (input === 'c') { + // Clear only property overrides (NOT color/bold/hide - those are managed separately) + const newRules = [...rules]; + const { color, backgroundColor, bold, hide, ...restApply } = rule.apply; + + // Preserve color/bold/hide if they exist + const newApply: Record = {}; + if (color !== undefined) { + newApply.color = color; + } + if (backgroundColor !== undefined) { + newApply.backgroundColor = backgroundColor; + } + if (bold !== undefined) { + newApply.bold = bold; + } + if (hide !== undefined) { + newApply.hide = hide; + } + + void restApply; // All other properties are cleared + + newRules[selectedIndex] = { + ...rule, + apply: newApply + }; + onUpdate({ ...widget, rules: newRules }); + return; + } + + // Handle widget property toggles ('r', 'm', custom keybinds) using shared logic + // Create temp widget by merging base widget with rule.apply + const tempWidget = mergeWidgetWithRuleApply(widget, rule.apply); + + handleWidgetPropertyInput({ + input, + key, + widget: tempWidget, + onUpdate: (updatedWidget) => { + // Extract what changed compared to base widget + const newApply = extractWidgetOverrides(updatedWidget, widget, rule.apply); + + const newRules = [...rules]; + newRules[selectedIndex] = { + ...rule, + apply: newApply + }; + onUpdate({ ...widget, rules: newRules }); + }, + getCustomKeybindsForWidget: (widgetImpl, w) => { + return widgetImpl.getCustomKeybinds?.(w) ?? []; + }, + setCustomEditorWidget + }); + }; + + // Handle move mode input + const handleMoveMode = (key: { upArrow?: boolean; downArrow?: boolean; return?: boolean; escape?: boolean }) => { + if (key.upArrow && selectedIndex > 0) { + // Swap with rule above + const newRules = [...rules]; + const currentRule = newRules[selectedIndex]; + const previousRule = newRules[selectedIndex - 1]; + if (!currentRule || !previousRule) { + return; + } + newRules[selectedIndex] = previousRule; + newRules[selectedIndex - 1] = currentRule; + + onUpdate({ ...widget, rules: newRules }); + setSelectedIndex(selectedIndex - 1); + } else if (key.downArrow && selectedIndex < rules.length - 1) { + // Swap with rule below + const newRules = [...rules]; + const currentRule = newRules[selectedIndex]; + const nextRule = newRules[selectedIndex + 1]; + if (!currentRule || !nextRule) { + return; + } + newRules[selectedIndex] = nextRule; + newRules[selectedIndex + 1] = currentRule; + + onUpdate({ ...widget, rules: newRules }); + setSelectedIndex(selectedIndex + 1); + } else if (key.escape || key.return) { + setMoveMode(false); + } + }; + + useInput((input, key) => { + if (customEditorWidget) { + return; // Let custom editor handle input + } + + if (moveMode) { + handleMoveMode(key); + return; + } + + if (conditionEditorIndex !== null) { + return; // Let ConditionEditor handle input + } + + if (key.escape) { + onBack(); + } else if (key.upArrow && rules.length > 0) { + setSelectedIndex(Math.max(0, selectedIndex - 1)); + } else if (key.downArrow && rules.length > 0) { + setSelectedIndex(Math.min(rules.length - 1, selectedIndex + 1)); + } else if (key.return && rules.length > 0) { + setMoveMode(true); // Enter move mode + } else if (key.tab && rules.length > 0) { + // Toggle between color and property modes + setEditorMode(prev => prev === 'color' ? 'property' : 'color'); + } else if (input === 'a') { + addRule(); + } else if (input === 'd' && rules.length > 0) { + deleteRule(); // Immediate delete, no confirmation (matches ItemsEditor) + } else if (rules.length > 0) { + // Handle mode-specific input + if (editorMode === 'color') { + // Color mode: inline editing + handleColorModeInput(input, key); + } else { + // Property mode: check for condition editor trigger first + if (key.leftArrow || key.rightArrow) { + // Open condition editor (same UX as ItemsEditor widget type picker) + setConditionEditorIndex(selectedIndex); + } else { + handlePropertyModeInput(input, key); + } + } + } + }); + + // Get widget display name (same as ColorMenu does) + const getWidgetDisplayName = () => { + const widgetImpl = getWidget(widget.type); + return widgetImpl ? widgetImpl.getDisplayName() : widget.type; + }; + + // Format condition summary + const formatCondition = (when: Record): string => { + const widgetRef = getConditionWidget(when); + const operator = getConditionOperator(when); + const value = getConditionValue(when); + const notFlag = getConditionNot(when); + + // Get widget display name + let widgetName = 'self'; + if (widgetRef !== 'self') { + const widgetImpl = getWidget(widgetRef); + widgetName = widgetImpl ? widgetImpl.getDisplayName() : widgetRef; + } + + // Check if this matches a display operator pattern + const displayOp = getDisplayOperator(when); + if (displayOp) { + const displayLabel = DISPLAY_OPERATOR_LABELS[displayOp]; + + // Format based on display operator type + if (displayOp === 'notEquals') { + return `when ${widgetName} ${displayLabel}${value}`; + } + if (displayOp === 'notContains' || displayOp === 'notStartsWith' || displayOp === 'notEndsWith') { + return `when ${widgetName} ${displayLabel} "${value}"`; + } + return `when ${widgetName} ${displayLabel}`; + } + + // Fall back to showing base operator with NOT prefix if needed + const notPrefix = notFlag ? 'NOT ' : ''; + + if (operator && value !== null) { + const opLabel = OPERATOR_LABELS[operator]; + + // Format based on operator type + if (isStringOperator(operator)) { + return `when ${notPrefix}${widgetName} ${opLabel} "${value}"`; + } + + if (isBooleanOperator(operator)) { + return `when ${notPrefix}${widgetName} ${opLabel}`; + } + + if (isSetOperator(operator) && Array.isArray(value)) { + const valueList = value.map(v => JSON.stringify(v)).join(', '); + return `when ${notPrefix}${widgetName} ${opLabel} [${valueList}]`; + } + + // Numeric or equals + return `when ${notPrefix}${widgetName} ${opLabel}${value}`; + } + + return `when ${JSON.stringify(when)}`; + }; + + // Format applied properties as labels (using widget's own display logic) + const formatAppliedProperties = (apply: Record): string => { + // Create a temp widget by merging base widget with rule.apply + // Use shared merge function to handle metadata deep merge correctly + const tempWidget = mergeWidgetWithRuleApply(widget, apply); + + // Let the widget format its own modifiers (hide, remaining, etc.) + const widgetImpl = getWidget(widget.type); + const { modifierText } = widgetImpl?.getEditorDisplay(tempWidget) ?? { modifierText: undefined }; + + // Build labels for base properties (rawValue, merge) + const baseLabels: string[] = []; + + if (tempWidget.rawValue) { + baseLabels.push('raw value'); + } + + if (tempWidget.merge === true) { + baseLabels.push('merged→'); + } else if (tempWidget.merge === 'no-padding') { + baseLabels.push('merged-no-pad→'); + } + + if (tempWidget.hide) { + baseLabels.push('hidden'); + } + + if (tempWidget.character !== undefined) { + baseLabels.push(`character: ${tempWidget.character}`); + } + + // Combine widget-specific modifiers and base property labels + const parts: string[] = []; + if (modifierText) { + parts.push(modifierText); + } + if (baseLabels.length > 0) { + parts.push(...baseLabels.map(l => `(${l})`)); + } + + return parts.length > 0 ? ` ${parts.join(' ')}` : ''; + }; + + // Build help text based on mode + const buildHelpText = (): string => { + if (moveMode) { + return '↑↓ move rule, Enter/ESC exit move mode'; + } + + if (rules.length === 0) { + return '(a)dd rule, ESC back'; + } + + const baseHelp = '↑↓ select, Enter move mode, (a)dd, (d)elete'; + + if (editorMode === 'color') { + const { editingBackground, hexInputMode, ansi256InputMode } = colorEditorState; + + if (hexInputMode) { + return 'Type 6-digit hex code (without #), Enter to apply, ESC to cancel'; + } + + if (ansi256InputMode) { + return 'Type ANSI 256 color code (0-255), Enter to apply, ESC to cancel'; + } + + const colorType = editingBackground ? 'background' : 'foreground'; + const hexAnsiHelp = settings.colorLevel === 3 + ? ', (h)ex' + : settings.colorLevel === 2 + ? ', (a)nsi256' + : ''; + + return `${baseHelp}, ←→ cycle ${colorType}\n(f) bg/fg, (b)old${hexAnsiHelp}, (r)eset\nTab: property mode, ESC back`; + } else { + // Property mode - include widget custom keybinds and base properties + const widgetImpl = getWidget(widget.type); + const customKeybinds = widgetImpl?.getCustomKeybinds?.(widget) ?? []; + const keybindHelp = customKeybinds + .map(kb => kb.label) + .join(', '); + + // Build base property keybinds + const basePropertyKeybinds: string[] = []; + if (widgetImpl?.supportsRawValue()) { + basePropertyKeybinds.push('(r)aw value'); + } + basePropertyKeybinds.push('(m)erge'); + basePropertyKeybinds.push('(h)ide'); + basePropertyKeybinds.push('(s)top'); + basePropertyKeybinds.push('(c)lear properties'); + + const propertyHelp = keybindHelp ? `, ${keybindHelp}` : ''; + const basePropsHelp = basePropertyKeybinds.join(', '); + return `${baseHelp}, ←→ edit condition${propertyHelp}, ${basePropsHelp}\nTab: color mode, ESC back`; + } + }; + + const helpText = buildHelpText(); + + // Show custom widget editor + if (customEditorWidget?.impl?.renderEditor) { + return customEditorWidget.impl.renderEditor({ + widget: customEditorWidget.widget, + onComplete: handleEditorComplete, + onCancel: handleEditorCancel, + action: customEditorWidget.action + }); + } + + // Show condition editor + if (conditionEditorIndex !== null) { + const rule = rules[conditionEditorIndex]; + if (!rule) { + setConditionEditorIndex(null); + return null; + } + + return ( + { + const newRules = [...rules]; + newRules[conditionEditorIndex] = { + ...rule, + when: newCondition + }; + onUpdate({ ...widget, rules: newRules }); + setConditionEditorIndex(null); + }} + onCancel={() => { setConditionEditorIndex(null); }} + /> + ); + } + + return ( + + + + Rules for + {widget.type} + + {moveMode && [MOVE MODE]} + {!moveMode && editorMode === 'color' && ( + + {' '} + [COLOR MODE + {colorEditorState.editingBackground ? ' - BACKGROUND' : ' - FOREGROUND'} + ] + + )} + {!moveMode && editorMode === 'property' && [PROPERTY MODE]} + + + {rules.length === 0 ? ( + No rules defined + ) : ( + <> + + {helpText} + + + {editorMode === 'color' && colorEditorState.hexInputMode && ( + + Enter 6-digit hex color code (without #): + + # + {colorEditorState.hexInput} + + {colorEditorState.hexInput.length < 6 ? '_'.repeat(6 - colorEditorState.hexInput.length) : ''} + + + + )} + + {editorMode === 'color' && colorEditorState.ansi256InputMode && ( + + Enter ANSI 256 color code (0-255): + + {colorEditorState.ansi256Input} + + {colorEditorState.ansi256Input.length === 0 + ? '___' + : colorEditorState.ansi256Input.length === 1 + ? '__' + : colorEditorState.ansi256Input.length === 2 + ? '_' + : ''} + + + + )} + + {editorMode === 'color' && !colorEditorState.hexInputMode && !colorEditorState.ansi256InputMode && (() => { + const rule = rules[selectedIndex]; + if (!rule) { + return null; + } + + // Create temp widget by merging base + apply + const tempWidget = mergeWidgetWithRuleApply(widget, rule.apply); + const { colorIndex, totalColors, displayName } = getCurrentColorInfo( + tempWidget, + colorEditorState.editingBackground + ); + + const colorType = colorEditorState.editingBackground ? 'background' : 'foreground'; + const colorNumber = colorIndex === -1 ? 'custom' : `${colorIndex}/${totalColors}`; + + // Apply color to display name + const level = getColorLevelString(settings.colorLevel); + const styledColor = colorEditorState.editingBackground + ? applyColors(` ${displayName} `, undefined, tempWidget.backgroundColor, false, level) + : applyColors(displayName, tempWidget.color, undefined, false, level); + + return ( + + + Current + {' '} + {colorType} + {' '} + ( + {colorNumber} + ): + {' '} + {styledColor} + {tempWidget.bold && [BOLD]} + + + ); + })()} + + {rules.map((rule, index) => { + const isSelected = index === selectedIndex; + const condition = formatCondition(rule.when); + const stopIndicator = rule.stop ? ' (stop)' : ''; + const appliedProps = formatAppliedProperties(rule.apply); + + // Get widget display name (same as ColorMenu pattern) + const displayName = getWidgetDisplayName(); + + // Get widget's actual configured color/bold as base + const widgetImpl = getWidget(widget.type); + const baseColor = widget.color ?? widgetImpl?.getDefaultColor() ?? 'white'; + const baseBackgroundColor = widget.backgroundColor; + const baseBold = widget.bold ?? false; + + // Apply rule overrides (or use widget's configured properties) + const color = rule.apply.color ? String(rule.apply.color) : baseColor; + const backgroundColor = rule.apply.backgroundColor ? String(rule.apply.backgroundColor) : baseBackgroundColor; + const bold = rule.apply.bold !== undefined ? Boolean(rule.apply.bold) : baseBold; + + // Apply colors (same as ColorMenu does) + const colorLevel = getColorLevelString(settings.colorLevel); + const styledLabel = applyColors(displayName, color, backgroundColor, bold, colorLevel); + + // Selection color: blue in move mode, green otherwise (same as ItemsEditor) + const selectionColor = isSelected ? (moveMode ? 'blue' : 'green') : undefined; + + return ( + + + + {isSelected ? (moveMode ? '◆ ' : '▶ ') : ' '} + + + {/* In move mode, override styling with selection color */} + {/* In normal mode, show rule's styled label */} + + {index + 1} + . + {moveMode ? displayName : styledLabel} + + + {(() => { + const fullText = ` (${condition})${stopIndicator}${appliedProps}`; + return fullText; + })()} + + + ); + })} + + )} + + + + {rules.length === 0 ? ( + {helpText} + ) : ( + `${rules.length} rule${rules.length === 1 ? '' : 's'}` + )} + + + + ); +}; \ No newline at end of file diff --git a/src/tui/components/StatusLinePreview.tsx b/src/tui/components/StatusLinePreview.tsx index 9db79dad..84e239db 100644 --- a/src/tui/components/StatusLinePreview.tsx +++ b/src/tui/components/StatusLinePreview.tsx @@ -17,6 +17,24 @@ import { } from '../../utils/renderer'; import { advanceGlobalSeparatorIndex } from '../../utils/separator-index'; +/** + * Create mock context data for preview mode that matches widget preview values + * This ensures rules evaluate correctly in preview + */ +function createPreviewContextData(): RenderContext['data'] { + return { + context_window: { + context_window_size: 200000, + total_input_tokens: 18600, // Results in ~9.3% used + total_output_tokens: 0, + current_usage: 18600, + used_percentage: 11.6, // Matches context-percentage-usable preview + remaining_percentage: 88.4 + }, + cost: { total_cost_usd: 2.45 } + }; +} + export interface StatusLinePreviewProps { lines: WidgetItem[][]; terminalWidth: number; @@ -33,12 +51,18 @@ const renderSingleLine = ( preRenderedWidgets: PreRenderedWidget[], preCalculatedMaxWidths: number[] ): RenderResult => { - // Create render context for preview + // Create render context for preview with mock data for rules evaluation const context: RenderContext = { + data: createPreviewContextData(), terminalWidth, isPreview: true, lineIndex, - globalSeparatorIndex + globalSeparatorIndex, + gitData: { + changedFiles: 3, + insertions: 42, + deletions: 18 + } }; return renderStatusLineWithInfo(widgets, settings, context, preRenderedWidgets, preCalculatedMaxWidths); @@ -52,7 +76,18 @@ 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 }); + // Include mock data for rules evaluation in preview + const previewContext: RenderContext = { + data: createPreviewContextData(), + terminalWidth, + isPreview: true, + gitData: { + changedFiles: 3, + insertions: 42, + deletions: 18 + } + }; + const preRenderedLines = preRenderAllWidgets(lines, settings, previewContext); const preCalculatedMaxWidths = calculateMaxWidthsFromPreRendered(preRenderedLines, settings); let globalSeparatorIndex = 0; diff --git a/src/tui/components/color-editor/input-handlers.ts b/src/tui/components/color-editor/input-handlers.ts new file mode 100644 index 00000000..38cadddc --- /dev/null +++ b/src/tui/components/color-editor/input-handlers.ts @@ -0,0 +1,264 @@ +import type { Settings } from '../../../types/Settings'; +import type { WidgetItem } from '../../../types/Widget'; +import { + getAvailableBackgroundColorsForUI, + getAvailableColorsForUI +} from '../../../utils/colors'; +import { + shouldInsertInput, + type InputKey +} from '../../../utils/input-guards'; +import { getWidget } from '../../../utils/widgets'; + +export type { InputKey }; + +export interface ColorEditorState { + editingBackground: boolean; + hexInputMode: boolean; + hexInput: string; + ansi256InputMode: boolean; + ansi256Input: string; +} + +export interface HandleColorInputArgs { + input: string; + key: InputKey; + widget: WidgetItem; + settings: Settings; + state: ColorEditorState; + setState: (updater: (prev: ColorEditorState) => ColorEditorState) => void; + onUpdate: (widget: WidgetItem) => void; + onReset?: () => void; +} + +function cycleColor( + currentColor: string, + colors: string[], + direction: 'left' | 'right' +): string { + if (colors.length === 0) { + return currentColor; + } + + let currentIndex = colors.indexOf(currentColor); + if (currentIndex === -1) { + currentIndex = 0; + } + + const nextIndex = direction === 'right' + ? (currentIndex + 1) % colors.length + : (currentIndex - 1 + colors.length) % colors.length; + + return colors[nextIndex] ?? currentColor; +} + +/** + * Shared color input handler for both ColorMenu and RulesEditor + * Returns true if input was handled, false otherwise + */ +export function handleColorInput({ + input, + key, + widget, + settings, + state, + setState, + onUpdate, + onReset +}: HandleColorInputArgs): boolean { + const { editingBackground, hexInputMode, hexInput, ansi256InputMode, ansi256Input } = state; + + // Handle hex input mode + if (hexInputMode) { + if (key.upArrow || key.downArrow) { + // Disable arrow keys in input mode + return true; + } + + if (key.escape) { + setState(prev => ({ ...prev, hexInputMode: false, hexInput: '' })); + return true; + } + + if (key.return) { + if (hexInput.length === 6) { + const hexColor = `hex:${hexInput}`; + const updatedWidget = editingBackground + ? { ...widget, backgroundColor: hexColor } + : { ...widget, color: hexColor }; + onUpdate(updatedWidget); + setState(prev => ({ ...prev, hexInputMode: false, hexInput: '' })); + } + return true; + } + + if (key.backspace || key.delete) { + setState(prev => ({ ...prev, hexInput: prev.hexInput.slice(0, -1) })); + return true; + } + + if (shouldInsertInput(input, key) && hexInput.length < 6) { + const upperInput = input.toUpperCase(); + if (/^[0-9A-F]$/.test(upperInput)) { + setState(prev => ({ ...prev, hexInput: prev.hexInput + upperInput })); + } + return true; + } + + return true; + } + + // Handle ANSI256 input mode + if (ansi256InputMode) { + if (key.upArrow || key.downArrow) { + // Disable arrow keys in input mode + return true; + } + + if (key.escape) { + setState(prev => ({ ...prev, ansi256InputMode: false, ansi256Input: '' })); + return true; + } + + if (key.return) { + const code = parseInt(ansi256Input, 10); + if (!isNaN(code) && code >= 0 && code <= 255) { + const ansiColor = `ansi256:${code}`; + const updatedWidget = editingBackground + ? { ...widget, backgroundColor: ansiColor } + : { ...widget, color: ansiColor }; + onUpdate(updatedWidget); + setState(prev => ({ ...prev, ansi256InputMode: false, ansi256Input: '' })); + } + return true; + } + + if (key.backspace || key.delete) { + setState(prev => ({ ...prev, ansi256Input: prev.ansi256Input.slice(0, -1) })); + return true; + } + + if (shouldInsertInput(input, key) && ansi256Input.length < 3) { + if (/^[0-9]$/.test(input)) { + const newInput = ansi256Input + input; + const code = parseInt(newInput, 10); + if (code <= 255) { + setState(prev => ({ ...prev, ansi256Input: newInput })); + } + } + return true; + } + + return true; + } + + // Normal color editing mode + if (input === 'h' && settings.colorLevel === 3) { + setState(prev => ({ ...prev, hexInputMode: true, hexInput: '' })); + return true; + } + + if (input === 'a' && settings.colorLevel === 2) { + setState(prev => ({ ...prev, ansi256InputMode: true, ansi256Input: '' })); + return true; + } + + if (input === 'f') { + setState(prev => ({ ...prev, editingBackground: !prev.editingBackground })); + return true; + } + + if (input === 'b') { + onUpdate({ ...widget, bold: !widget.bold }); + return true; + } + + if (input === 'r') { + if (onReset) { + onReset(); + } else { + // Default reset: remove color/backgroundColor/bold + const { color, backgroundColor, bold, ...rest } = widget; + void color; + void backgroundColor; + void bold; + onUpdate(rest); + } + return true; + } + + if (key.leftArrow || key.rightArrow) { + const colors = editingBackground + ? getAvailableBackgroundColorsForUI().map(c => c.value) + : getAvailableColorsForUI().map(c => c.value); + + const widgetImpl = getWidget(widget.type); + const defaultColor = widgetImpl?.getDefaultColor() ?? 'white'; + + const currentColor = editingBackground + ? (widget.backgroundColor ?? '') + : (widget.color ?? defaultColor); + + const nextColor = cycleColor( + currentColor, + colors, + key.rightArrow ? 'right' : 'left' + ); + + const updatedWidget = editingBackground + ? { ...widget, backgroundColor: nextColor === '' ? undefined : nextColor } + : { ...widget, color: nextColor }; + + onUpdate(updatedWidget); + return true; + } + + return false; +} + +/** + * Get current color info for display + */ +export function getCurrentColorInfo( + widget: WidgetItem, + editingBackground: boolean +): { + currentColor: string; + colorIndex: number; + totalColors: number; + displayName: string; +} { + const colorOptions = editingBackground + ? getAvailableBackgroundColorsForUI() + : getAvailableColorsForUI(); + + const colors = colorOptions.map(c => c.value); + const widgetImpl = getWidget(widget.type); + const defaultColor = widgetImpl?.getDefaultColor() ?? 'white'; + + const currentColor = editingBackground + ? (widget.backgroundColor ?? '') + : (widget.color ?? defaultColor); + + const colorIndex = colors.indexOf(currentColor); + + // Determine display name + let displayName: string; + if (!currentColor || currentColor === '') { + displayName = editingBackground ? '(no background)' : '(default)'; + } else if (currentColor.startsWith('ansi256:')) { + displayName = `ANSI ${currentColor.substring(8)}`; + } else if (currentColor.startsWith('hex:')) { + displayName = `#${currentColor.substring(4)}`; + } else { + const option = colorOptions.find(c => c.value === currentColor); + displayName = option ? option.name : currentColor; + } + + return { + currentColor, + colorIndex: colorIndex === -1 ? -1 : colorIndex + 1, // 1-indexed for display + totalColors: colors.length, + displayName + }; +} \ No newline at end of file diff --git a/src/tui/components/items-editor/__tests__/input-handlers.test.ts b/src/tui/components/items-editor/__tests__/input-handlers.test.ts index 160d84ca..1a068043 100644 --- a/src/tui/components/items-editor/__tests__/input-handlers.test.ts +++ b/src/tui/components/items-editor/__tests__/input-handlers.test.ts @@ -167,7 +167,8 @@ describe('items-editor input handlers', () => { setShowClearConfirm: vi.fn(), openWidgetPicker: vi.fn(), getCustomKeybindsForWidget: (widgetImpl, widget) => widgetImpl.getCustomKeybinds ? widgetImpl.getCustomKeybinds(widget) : [], - setCustomEditorWidget: vi.fn() + setCustomEditorWidget: vi.fn(), + setRulesEditorWidget: vi.fn() }); const updated = onUpdate.mock.calls[0]?.[0] as WidgetItem[] | undefined; @@ -193,7 +194,8 @@ describe('items-editor input handlers', () => { setShowClearConfirm: vi.fn(), openWidgetPicker: vi.fn(), getCustomKeybindsForWidget: (widgetImpl, widget) => widgetImpl.getCustomKeybinds ? widgetImpl.getCustomKeybinds(widget) : [], - setCustomEditorWidget: vi.fn() + setCustomEditorWidget: vi.fn(), + setRulesEditorWidget: vi.fn() }); const updated = onUpdate.mock.calls[0]?.[0] as WidgetItem[] | undefined; @@ -219,7 +221,8 @@ describe('items-editor input handlers', () => { setShowClearConfirm: vi.fn(), openWidgetPicker: vi.fn(), getCustomKeybindsForWidget: (widgetImpl, widget) => widgetImpl.getCustomKeybinds ? widgetImpl.getCustomKeybinds(widget) : [], - setCustomEditorWidget: vi.fn() + setCustomEditorWidget: vi.fn(), + setRulesEditorWidget: vi.fn() }); const updated = onUpdate.mock.calls[0]?.[0] as WidgetItem[] | undefined; @@ -245,7 +248,8 @@ describe('items-editor input handlers', () => { setShowClearConfirm: vi.fn(), openWidgetPicker: vi.fn(), getCustomKeybindsForWidget: (widgetImpl, widget) => widgetImpl.getCustomKeybinds ? widgetImpl.getCustomKeybinds(widget) : [], - setCustomEditorWidget: vi.fn() + setCustomEditorWidget: vi.fn(), + setRulesEditorWidget: vi.fn() }); const updated = onUpdate.mock.calls[0]?.[0] as WidgetItem[] | undefined; @@ -272,7 +276,8 @@ describe('items-editor input handlers', () => { setShowClearConfirm: vi.fn(), openWidgetPicker: vi.fn(), getCustomKeybindsForWidget: (widgetImpl, widget) => widgetImpl.getCustomKeybinds ? widgetImpl.getCustomKeybinds(widget) : [], - setCustomEditorWidget + setCustomEditorWidget, + setRulesEditorWidget: vi.fn() }); expect(onUpdate).not.toHaveBeenCalled(); diff --git a/src/tui/components/items-editor/input-handlers.ts b/src/tui/components/items-editor/input-handlers.ts index 7dafe493..ff01074d 100644 --- a/src/tui/components/items-editor/input-handlers.ts +++ b/src/tui/components/items-editor/input-handlers.ts @@ -4,12 +4,19 @@ import type { WidgetItem, WidgetItemType } from '../../../types/Widget'; +import type { InputKey } from '../../../utils/input-guards'; +import { + toggleWidgetMerge, + toggleWidgetRawValue +} from '../../../utils/widget-properties'; import { filterWidgetCatalog, getWidget, type WidgetCatalogEntry } from '../../../utils/widgets'; +export type { InputKey }; + export type WidgetPickerAction = 'change' | 'add' | 'insert'; export type WidgetPickerLevel = 'category' | 'widget'; @@ -28,21 +35,6 @@ export interface CustomEditorWidgetState { action?: string; } -export interface InputKey { - ctrl?: boolean; - meta?: boolean; - tab?: boolean; - shift?: boolean; - upArrow?: boolean; - downArrow?: boolean; - leftArrow?: boolean; - rightArrow?: boolean; - return?: boolean; - escape?: boolean; - backspace?: boolean; - delete?: boolean; -} - type Setter = (value: T | ((prev: T) => T)) => void; function setPickerState( @@ -326,6 +318,76 @@ export function handleMoveInputMode({ } } +/** + * Handle widget property editing (raw value, merge, custom keybinds) + * Shared between ItemsEditor and RulesEditor + */ +export interface HandleWidgetPropertyInputArgs { + input: string; + key: InputKey; + widget: WidgetItem; + onUpdate: (updatedWidget: WidgetItem) => void; + getCustomKeybindsForWidget: (widgetImpl: Widget, widget: WidgetItem) => CustomKeybind[]; + setCustomEditorWidget?: (state: CustomEditorWidgetState | null) => void; +} + +export function handleWidgetPropertyInput({ + input, + key, + widget, + onUpdate, + getCustomKeybindsForWidget, + setCustomEditorWidget +}: HandleWidgetPropertyInputArgs): boolean { + // Handle raw value toggle + if (input === 'r') { + const widgetImpl = getWidget(widget.type); + if (!widgetImpl?.supportsRawValue()) { + return false; + } + onUpdate(toggleWidgetRawValue(widget)); + return true; + } + + // Handle merge toggle + if (input === 'm') { + onUpdate(toggleWidgetMerge(widget)); + return true; + } + + // Handle widget-specific custom keybinds + const widgetImpl = getWidget(widget.type); + if (!widgetImpl?.getCustomKeybinds) { + return false; + } + + const customKeybinds = getCustomKeybindsForWidget(widgetImpl, widget); + const matchedKeybind = customKeybinds.find(kb => kb.key === input); + + if (matchedKeybind && !key.ctrl) { + // Try handleEditorAction first (for widgets that handle actions directly) + if (widgetImpl.handleEditorAction) { + const updatedWidget = widgetImpl.handleEditorAction(matchedKeybind.action, widget); + if (updatedWidget) { + onUpdate(updatedWidget); + return true; + } + } + + // If no handleEditorAction or it didn't return a widget, check for renderEditor + if (widgetImpl.renderEditor && setCustomEditorWidget) { + setCustomEditorWidget({ + widget, + impl: widgetImpl, + action: matchedKeybind.action + }); + return true; + } + } + + return false; +} + export interface HandleNormalInputModeArgs { input: string; key: InputKey; @@ -340,6 +402,7 @@ export interface HandleNormalInputModeArgs { openWidgetPicker: (action: WidgetPickerAction) => void; getCustomKeybindsForWidget: (widgetImpl: Widget, widget: WidgetItem) => CustomKeybind[]; setCustomEditorWidget: (state: CustomEditorWidgetState | null) => void; + setRulesEditorWidget: (widget: WidgetItem | null) => void; } export function handleNormalInputMode({ @@ -355,7 +418,8 @@ export function handleNormalInputMode({ setShowClearConfirm, openWidgetPicker, getCustomKeybindsForWidget, - setCustomEditorWidget + setCustomEditorWidget, + setRulesEditorWidget }: HandleNormalInputModeArgs): void { if (key.upArrow && widgets.length > 0) { setSelectedIndex(Math.max(0, selectedIndex - 1)); @@ -377,10 +441,8 @@ export function handleNormalInputMode({ if (selectedIndex >= newWidgets.length && selectedIndex > 0) { setSelectedIndex(selectedIndex - 1); } - } else if (input === 'c') { - if (widgets.length > 0) { - setShowClearConfirm(true); - } + } else if (input === 'c' && widgets.length > 0) { + setShowClearConfirm(true); } else if (input === ' ' && widgets.length > 0) { const currentWidget = widgets[selectedIndex]; if (currentWidget?.type === 'separator') { @@ -391,68 +453,34 @@ export function handleNormalInputMode({ newWidgets[selectedIndex] = { ...currentWidget, character: nextChar }; onUpdate(newWidgets); } - } else if (input === 'r' && widgets.length > 0) { + } else if (input === 'x' && widgets.length > 0) { const currentWidget = widgets[selectedIndex]; if (currentWidget && currentWidget.type !== 'separator' && currentWidget.type !== 'flex-separator') { - const widgetImpl = getWidget(currentWidget.type); - if (!widgetImpl?.supportsRawValue()) { - return; - } - const newWidgets = [...widgets]; - newWidgets[selectedIndex] = { ...currentWidget, rawValue: !currentWidget.rawValue }; - onUpdate(newWidgets); - } - } else if (input === 'm' && widgets.length > 0) { - const currentWidget = widgets[selectedIndex]; - if (currentWidget && selectedIndex < widgets.length - 1 - && currentWidget.type !== 'separator' && currentWidget.type !== 'flex-separator') { - const newWidgets = [...widgets]; - let nextMergeState: boolean | 'no-padding' | undefined; - - if (currentWidget.merge === undefined) { - nextMergeState = true; - } else if (currentWidget.merge === true) { - nextMergeState = 'no-padding'; - } else { - nextMergeState = undefined; - } - - if (nextMergeState === undefined) { - const { merge, ...rest } = currentWidget; - void merge; // Intentionally unused - newWidgets[selectedIndex] = rest; - } else { - newWidgets[selectedIndex] = { ...currentWidget, merge: nextMergeState }; - } - onUpdate(newWidgets); + setRulesEditorWidget(currentWidget); } } else if (key.escape) { onBack(); } else if (widgets.length > 0) { + // Try widget property input ('r', 'm', custom keybinds) const currentWidget = widgets[selectedIndex]; if (currentWidget && currentWidget.type !== 'separator' && currentWidget.type !== 'flex-separator') { - const widgetImpl = getWidget(currentWidget.type); - if (!widgetImpl?.getCustomKeybinds) { + // Special case for merge: only allow if not the last widget + if (input === 'm' && selectedIndex >= widgets.length - 1) { return; } - const customKeybinds = getCustomKeybindsForWidget(widgetImpl, currentWidget); - const matchedKeybind = customKeybinds.find(kb => kb.key === input); - - if (matchedKeybind && !key.ctrl) { - if (widgetImpl.handleEditorAction) { - const updatedWidget = widgetImpl.handleEditorAction(matchedKeybind.action, currentWidget); - if (updatedWidget) { - const newWidgets = [...widgets]; - newWidgets[selectedIndex] = updatedWidget; - onUpdate(newWidgets); - } else if (widgetImpl.renderEditor) { - setCustomEditorWidget({ widget: currentWidget, impl: widgetImpl, action: matchedKeybind.action }); - } - } else if (widgetImpl.renderEditor) { - setCustomEditorWidget({ widget: currentWidget, impl: widgetImpl, action: matchedKeybind.action }); - } - } + handleWidgetPropertyInput({ + input, + key, + widget: currentWidget, + onUpdate: (updatedWidget) => { + const newWidgets = [...widgets]; + newWidgets[selectedIndex] = updatedWidget; + onUpdate(newWidgets); + }, + getCustomKeybindsForWidget, + setCustomEditorWidget + }); } } } \ No newline at end of file diff --git a/src/types/Condition.ts b/src/types/Condition.ts new file mode 100644 index 00000000..19c85b3e --- /dev/null +++ b/src/types/Condition.ts @@ -0,0 +1,165 @@ +// Operator types +export type NumericOperator + = | 'greaterThan' + | 'lessThan' + | 'equals' + | 'greaterThanOrEqual' + | 'lessThanOrEqual'; + +export type StringOperator + = | 'contains' + | 'startsWith' + | 'endsWith'; + +export type BooleanOperator + = | 'isTrue'; + +export type SetOperator + = | 'in' + | 'notIn'; + +export type Operator = NumericOperator | StringOperator | BooleanOperator | SetOperator; + +export const NUMERIC_OPERATORS: NumericOperator[] = [ + 'greaterThan', + 'greaterThanOrEqual', + 'lessThan', + 'lessThanOrEqual', + 'equals' +]; + +export const STRING_OPERATORS: StringOperator[] = [ + 'contains', + 'startsWith', + 'endsWith' +]; + +export const BOOLEAN_OPERATORS: BooleanOperator[] = [ + 'isTrue' +]; + +export const SET_OPERATORS: SetOperator[] = [ + 'in', + 'notIn' +]; + +export const ALL_OPERATORS: Operator[] = [ + ...NUMERIC_OPERATORS, + ...STRING_OPERATORS, + ...BOOLEAN_OPERATORS, + ...SET_OPERATORS +]; + +// Display labels for operators +export const OPERATOR_LABELS: Record = { + // Numeric + greaterThan: '>', + greaterThanOrEqual: '≥', + lessThan: '<', + lessThanOrEqual: '≤', + equals: '=', + // String + contains: 'contains', + startsWith: 'starts with', + endsWith: 'ends with', + // Boolean + isTrue: 'is true', + // Set + in: 'in', + notIn: 'not in' +}; + +// Display-only operators - syntactic sugar that maps to base operator + not flag +export type DisplayOperator + = | 'notEquals' // equals + not + | 'notContains' // contains + not + | 'notStartsWith' // startsWith + not + | 'notEndsWith' // endsWith + not + | 'isFalse'; // isTrue: false (special case, not using not flag) + +export const DISPLAY_OPERATOR_LABELS: Record = { + notEquals: '≠', + notContains: 'does not contain', + notStartsWith: 'does not start with', + notEndsWith: 'does not end with', + isFalse: 'is false' +}; + +// Map display operators to their base operator + not flag +export const DISPLAY_OPERATOR_CONFIG: Record = { + notEquals: { operator: 'equals', not: true }, + notContains: { operator: 'contains', not: true }, + notStartsWith: { operator: 'startsWith', not: true }, + notEndsWith: { operator: 'endsWith', not: true }, + isFalse: { operator: 'isTrue', value: false } // Special: isTrue with value false +}; + +// Get display operator if condition matches a display operator pattern +export function getDisplayOperator(when: Record): DisplayOperator | null { + const operator = getConditionOperator(when); + if (!operator) { + return null; + } + + const notFlag = getConditionNot(when); + const value = when[operator]; + + if (operator === 'equals' && notFlag) + return 'notEquals'; + if (operator === 'contains' && notFlag) + return 'notContains'; + if (operator === 'startsWith' && notFlag) + return 'notStartsWith'; + if (operator === 'endsWith' && notFlag) + return 'notEndsWith'; + if (operator === 'isTrue' && value === false) + return 'isFalse'; + + return null; +} + +// Helper to get widget reference from condition (defaults to 'self') +export function getConditionWidget(when: Record): string { + const widget = when.widget; + return typeof widget === 'string' ? widget : 'self'; +} + +// Helper to get negation flag from condition (defaults to false) +export function getConditionNot(when: Record): boolean { + const not = when.not; + return typeof not === 'boolean' ? not : false; +} + +// Helper to get operator from condition +export function getConditionOperator(when: Record): Operator | null { + for (const op of ALL_OPERATORS) { + if (op in when) + return op; + } + return null; +} + +// Helper to get value from condition (works for all operator types) +export function getConditionValue(when: Record): number | string | boolean | (string | number)[] | null { + const op = getConditionOperator(when); + if (!op) + return null; + return when[op] as number | string | boolean | (string | number)[] | null; +} + +// Type guards for operators +export function isNumericOperator(op: Operator): op is NumericOperator { + return NUMERIC_OPERATORS.includes(op as NumericOperator); +} + +export function isStringOperator(op: Operator): op is StringOperator { + return STRING_OPERATORS.includes(op as StringOperator); +} + +export function isBooleanOperator(op: Operator): op is BooleanOperator { + return BOOLEAN_OPERATORS.includes(op as BooleanOperator); +} + +export function isSetOperator(op: Operator): op is SetOperator { + return SET_OPERATORS.includes(op as SetOperator); +} \ No newline at end of file diff --git a/src/types/Widget.ts b/src/types/Widget.ts index a62af252..573d7ad7 100644 --- a/src/types/Widget.ts +++ b/src/types/Widget.ts @@ -20,7 +20,12 @@ export const WidgetItemSchema = z.object({ timeout: z.number().optional(), merge: z.union([z.boolean(), z.literal('no-padding')]).optional(), hide: z.boolean().optional(), - metadata: z.record(z.string(), z.string()).optional() + metadata: z.record(z.string(), z.string()).optional(), + rules: z.array(z.object({ + when: z.record(z.string(), z.any()), // flexible for now (includes optional 'not' flag) + apply: z.record(z.string(), z.any()), // any widget properties + stop: z.boolean().optional() + })).optional() }); // Inferred types from Zod schemas diff --git a/src/types/__tests__/Condition.test.ts b/src/types/__tests__/Condition.test.ts new file mode 100644 index 00000000..a0d53491 --- /dev/null +++ b/src/types/__tests__/Condition.test.ts @@ -0,0 +1,107 @@ +import { + describe, + expect, + test +} from 'vitest'; + +import { + DISPLAY_OPERATOR_LABELS, + OPERATOR_LABELS, + getConditionOperator, + getConditionValue, + getDisplayOperator +} from '../Condition'; + +describe('Condition utilities', () => { + test('getConditionOperator extracts operator', () => { + // Numeric operators + expect(getConditionOperator({ greaterThan: 50 })).toBe('greaterThan'); + expect(getConditionOperator({ lessThan: 100 })).toBe('lessThan'); + expect(getConditionOperator({ equals: 42 })).toBe('equals'); + expect(getConditionOperator({ greaterThanOrEqual: 75 })).toBe('greaterThanOrEqual'); + expect(getConditionOperator({ lessThanOrEqual: 25 })).toBe('lessThanOrEqual'); + + // String operators + expect(getConditionOperator({ contains: 'text' })).toBe('contains'); + expect(getConditionOperator({ startsWith: 'prefix' })).toBe('startsWith'); + expect(getConditionOperator({ endsWith: 'suffix' })).toBe('endsWith'); + + // Boolean operators + expect(getConditionOperator({ isTrue: true })).toBe('isTrue'); + + // Set operators + expect(getConditionOperator({ in: ['a', 'b'] })).toBe('in'); + expect(getConditionOperator({ notIn: ['x', 'y'] })).toBe('notIn'); + + // No operator + expect(getConditionOperator({})).toBeNull(); + }); + + test('getConditionValue extracts value (supports multiple types)', () => { + // Numeric values + expect(getConditionValue({ greaterThan: 50 })).toBe(50); + expect(getConditionValue({ lessThan: 100.5 })).toBe(100.5); + expect(getConditionValue({ equals: 0 })).toBe(0); + expect(getConditionValue({ greaterThanOrEqual: -10 })).toBe(-10); + expect(getConditionValue({ lessThanOrEqual: 99.99 })).toBe(99.99); + + // String values + expect(getConditionValue({ contains: 'feature/' })).toBe('feature/'); + expect(getConditionValue({ startsWith: 'prefix' })).toBe('prefix'); + + // Boolean values + expect(getConditionValue({ isTrue: true })).toBe(true); + expect(getConditionValue({ isTrue: false })).toBe(false); + + // Array values + expect(getConditionValue({ in: ['a', 'b', 'c'] })).toEqual(['a', 'b', 'c']); + + // No operator + expect(getConditionValue({})).toBeNull(); + }); + + test('all operators have labels', () => { + expect(OPERATOR_LABELS.greaterThan).toBe('>'); + expect(OPERATOR_LABELS.greaterThanOrEqual).toBe('≥'); + expect(OPERATOR_LABELS.lessThan).toBe('<'); + expect(OPERATOR_LABELS.lessThanOrEqual).toBe('≤'); + expect(OPERATOR_LABELS.equals).toBe('='); + }); + + test('handles invalid conditions gracefully', () => { + // Unknown operator + expect(getConditionOperator({ unknown: 50 })).toBeNull(); + expect(getConditionValue({ unknown: 50 })).toBeNull(); + + // Type mismatches are allowed in extraction (validated during evaluation) + expect(getConditionValue({ greaterThan: 'not a number' })).toBe('not a number'); + }); + + test('display operators - detects patterns', () => { + // notEquals: equals + not + expect(getDisplayOperator({ equals: 50, not: true })).toBe('notEquals'); + expect(getDisplayOperator({ equals: 50 })).toBeNull(); + + // notContains: contains + not + expect(getDisplayOperator({ contains: 'feature/', not: true })).toBe('notContains'); + expect(getDisplayOperator({ contains: 'feature/' })).toBeNull(); + + // notStartsWith: startsWith + not + expect(getDisplayOperator({ startsWith: 'prefix', not: true })).toBe('notStartsWith'); + + // notEndsWith: endsWith + not + expect(getDisplayOperator({ endsWith: 'suffix', not: true })).toBe('notEndsWith'); + + // isFalse: isTrue with value false + expect(getDisplayOperator({ isTrue: false })).toBe('isFalse'); + expect(getDisplayOperator({ isTrue: true })).toBeNull(); + }); + + test('display operators have labels', () => { + expect(DISPLAY_OPERATOR_LABELS.notEquals).toBe('≠'); + expect(DISPLAY_OPERATOR_LABELS.notContains).toBe('does not contain'); + expect(DISPLAY_OPERATOR_LABELS.notStartsWith).toBe('does not start with'); + expect(DISPLAY_OPERATOR_LABELS.notEndsWith).toBe('does not end with'); + expect(DISPLAY_OPERATOR_LABELS.isFalse).toBe('is false'); + }); +}); \ No newline at end of file diff --git a/src/types/__tests__/Widget.test.ts b/src/types/__tests__/Widget.test.ts new file mode 100644 index 00000000..c9e394ce --- /dev/null +++ b/src/types/__tests__/Widget.test.ts @@ -0,0 +1,87 @@ +import { + describe, + expect, + test +} from 'vitest'; + +import { WidgetItemSchema } from '../Widget'; + +describe('WidgetItemSchema', () => { + test('accepts widget without rules', () => { + const widget = { + id: 'test-1', + type: 'model', + color: 'cyan' + }; + + const result = WidgetItemSchema.safeParse(widget); + expect(result.success).toBe(true); + }); + + test('accepts widget with rules array', () => { + const widget = { + id: 'test-2', + type: 'context-percentage', + color: 'white', + rules: [ + { + when: { greaterThan: 75 }, + apply: { color: 'red', bold: true }, + stop: true + }, + { + when: { greaterThan: 50 }, + apply: { color: 'yellow' }, + stop: true + } + ] + }; + + const result = WidgetItemSchema.safeParse(widget); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.rules).toBeDefined(); + expect(result.data.rules?.length).toBe(2); + expect(result.data.rules?.[0]?.when.greaterThan).toBe(75); + expect(result.data.rules?.[0]?.apply.color).toBe('red'); + expect(result.data.rules?.[0]?.apply.bold).toBe(true); + expect(result.data.rules?.[0]?.stop).toBe(true); + } + }); + + test('accepts widget with empty rules array', () => { + const widget = { + id: 'test-3', + type: 'model', + color: 'cyan', + rules: [] + }; + + const result = WidgetItemSchema.safeParse(widget); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.rules).toBeDefined(); + expect(result.data.rules?.length).toBe(0); + } + }); + + test('accepts rule without stop property', () => { + const widget = { + id: 'test-4', + type: 'context-percentage', + color: 'white', + rules: [ + { + when: { greaterThan: 50 }, + apply: { color: 'yellow' } + } + ] + }; + + const result = WidgetItemSchema.safeParse(widget); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.rules?.[0]?.stop).toBeUndefined(); + } + }); +}); \ No newline at end of file diff --git a/src/utils/__tests__/renderer-rules.test.ts b/src/utils/__tests__/renderer-rules.test.ts new file mode 100644 index 00000000..49500c4d --- /dev/null +++ b/src/utils/__tests__/renderer-rules.test.ts @@ -0,0 +1,234 @@ +import { + describe, + expect, + test, + vi +} from 'vitest'; + +import type { RenderContext } from '../../types/RenderContext'; +import { DEFAULT_SETTINGS } from '../../types/Settings'; +import type { WidgetItem } from '../../types/Widget'; +import * as colorsModule from '../colors'; +import { + preRenderAllWidgets, + renderStatusLine +} from '../renderer'; + +describe('Renderer with Rules', () => { + const baseSettings = { + ...DEFAULT_SETTINGS, + colorLevel: 3 as const, + powerline: { + ...DEFAULT_SETTINGS.powerline, + enabled: false + } + }; + + const createContext = (usedPercentage: number): RenderContext => ({ + data: { + context_window: { + context_window_size: 200000, + total_input_tokens: usedPercentage * 2000, + total_output_tokens: 0, + current_usage: usedPercentage * 2000, + used_percentage: usedPercentage, + remaining_percentage: 100 - usedPercentage + } + }, + terminalWidth: 120, + isPreview: false + }); + + test('preRenderAllWidgets applies rule color when condition matches', () => { + const widget: WidgetItem = { + id: 'test-1', + type: 'context-percentage', + color: 'green', + rules: [ + { + when: { greaterThan: 50 }, + apply: { color: 'red' }, + stop: false + } + ] + }; + + const lines = [[widget]]; + + // 80% should trigger rule + const highContext = createContext(80); + const preRendered = preRenderAllWidgets(lines, baseSettings, highContext); + + // The preRendered widget should have red color applied + expect(preRendered[0]?.[0]?.widget.color).toBe('red'); + }); + + test('preRenderAllWidgets keeps base color when condition does not match', () => { + const widget: WidgetItem = { + id: 'test-1', + type: 'context-percentage', + color: 'green', + rules: [ + { + when: { greaterThan: 50 }, + apply: { color: 'red' }, + stop: false + } + ] + }; + + const lines = [[widget]]; + + // 30% should NOT trigger rule + const lowContext = createContext(30); + const preRendered = preRenderAllWidgets(lines, baseSettings, lowContext); + + // The preRendered widget should keep green color + expect(preRendered[0]?.[0]?.widget.color).toBe('green'); + }); + + test('preRenderAllWidgets applies rule bold override', () => { + const widget: WidgetItem = { + id: 'test-1', + type: 'context-percentage', + color: 'white', + bold: false, + rules: [ + { + when: { greaterThan: 50 }, + apply: { bold: true }, + stop: false + } + ] + }; + + const lines = [[widget]]; + const highContext = createContext(80); + const preRendered = preRenderAllWidgets(lines, baseSettings, highContext); + + expect(preRendered[0]?.[0]?.widget.bold).toBe(true); + }); + + test('preRenderAllWidgets applies rule background color', () => { + const widget: WidgetItem = { + id: 'test-1', + type: 'context-percentage', + color: 'white', + rules: [ + { + when: { greaterThan: 50 }, + apply: { backgroundColor: 'red' }, + stop: false + } + ] + }; + + const lines = [[widget]]; + const highContext = createContext(80); + const preRendered = preRenderAllWidgets(lines, baseSettings, highContext); + + expect(preRendered[0]?.[0]?.widget.backgroundColor).toBe('red'); + }); + + test('multiple rules accumulate properties when stop is false', () => { + const widget: WidgetItem = { + id: 'test-1', + type: 'context-percentage', + color: 'white', + rules: [ + { + when: { greaterThan: 30 }, + apply: { bold: true }, + stop: false + }, + { + when: { greaterThan: 50 }, + apply: { color: 'red' }, + stop: false + } + ] + }; + + const lines = [[widget]]; + const highContext = createContext(80); + const preRendered = preRenderAllWidgets(lines, baseSettings, highContext); + + // Both rules should apply + expect(preRendered[0]?.[0]?.widget.bold).toBe(true); + expect(preRendered[0]?.[0]?.widget.color).toBe('red'); + }); + + test('renderStatusLine uses rule-applied color, not base widget color', () => { + // This test would FAIL without the fix to use effectiveWidget + const applyColorsSpy = vi.spyOn(colorsModule, 'applyColors'); + + const widget: WidgetItem = { + id: 'test-1', + type: 'context-percentage', + color: 'green', // Base color + rules: [ + { + when: { greaterThan: 50 }, + apply: { color: 'red' }, // Rule overrides to red + stop: false + } + ] + }; + + const lines = [[widget]]; + const highContext = createContext(80); // 80% > 50%, rule matches + const preRendered = preRenderAllWidgets(lines, baseSettings, highContext); + + // Verify preRender applied the rule + expect(preRendered[0]?.[0]?.widget.color).toBe('red'); + + // Now render - this is where the bug was + renderStatusLine([widget], baseSettings, highContext, preRendered[0] ?? [], []); + + // Find the call that rendered our widget (not separators, etc) + // applyColors is called with (text, fgColor, bgColor, bold, level) + const widgetRenderCall = applyColorsSpy.mock.calls.find( + call => call[0].includes('80.0%') // Our widget content + ); + + expect(widgetRenderCall).toBeDefined(); + // The foreground color (2nd arg) should be 'red' from the rule, NOT 'green' from base + expect(widgetRenderCall?.[1]).toBe('red'); + + applyColorsSpy.mockRestore(); + }); + + test('renderStatusLine uses rule-applied bold, not base widget bold', () => { + const applyColorsSpy = vi.spyOn(colorsModule, 'applyColors'); + + const widget: WidgetItem = { + id: 'test-1', + type: 'context-percentage', + color: 'white', + bold: false, // Base: not bold + rules: [ + { + when: { greaterThan: 50 }, + apply: { bold: true }, // Rule makes it bold + stop: false + } + ] + }; + + const lines = [[widget]]; + const highContext = createContext(80); + const preRendered = preRenderAllWidgets(lines, baseSettings, highContext); + + renderStatusLine([widget], baseSettings, highContext, preRendered[0] ?? [], []); + + const widgetRenderCall = applyColorsSpy.mock.calls.find( + call => call[0].includes('80.0%') + ); + + expect(widgetRenderCall).toBeDefined(); + // The bold flag (4th arg) should be true from the rule + expect(widgetRenderCall?.[3]).toBe(true); + + applyColorsSpy.mockRestore(); + }); +}); \ No newline at end of file diff --git a/src/utils/__tests__/rules-engine.test.ts b/src/utils/__tests__/rules-engine.test.ts new file mode 100644 index 00000000..74f70525 --- /dev/null +++ b/src/utils/__tests__/rules-engine.test.ts @@ -0,0 +1,940 @@ +import { execSync } from 'child_process'; +import { + beforeEach, + describe, + expect, + test, + vi +} from 'vitest'; + +import type { RenderContext } from '../../types/RenderContext'; +import { clearGitCache } from '../git'; +import { applyRules } from '../rules-engine'; + +// Mock child_process +vi.mock('child_process', () => ({ execSync: vi.fn() })); + +const mockExecSync = execSync as unknown as { + mockReturnValue: (value: string) => void; + mockReturnValueOnce: (value: string) => void; + mockImplementation: (impl: () => never) => void; +}; + +describe('Rules Engine', () => { + beforeEach(() => { + vi.clearAllMocks(); + clearGitCache(); + }); + + const mockContext: RenderContext = { + data: { + context_window: { + context_window_size: 200000, + total_input_tokens: 150000, + total_output_tokens: 10000, + current_usage: null, + used_percentage: 80, + remaining_percentage: 20 + } + } + }; + + test('returns original item when no rules', () => { + const item = { + id: 'test', + type: 'context-percentage', + color: 'white' + }; + + const result = applyRules(item, mockContext, [item]); + expect(result).toEqual(item); + }); + + test('applies color override when condition matches', () => { + const item = { + id: 'test', + type: 'context-percentage', + color: 'white', + rules: [ + { + when: { greaterThan: 50 }, + apply: { color: 'yellow' } + } + ] + }; + + const result = applyRules(item, mockContext, [item]); + expect(result.color).toBe('yellow'); + }); + + test('keeps original color when condition does not match', () => { + const item = { + id: 'test', + type: 'context-percentage', + color: 'white', + rules: [ + { + when: { greaterThan: 90 }, + apply: { color: 'red' } + } + ] + }; + + const result = applyRules(item, mockContext, [item]); + expect(result.color).toBe('white'); + }); + + test('evaluates rules top-to-bottom', () => { + const item = { + id: 'test', + type: 'context-percentage', + color: 'white', + rules: [ + { + when: { greaterThan: 50 }, + apply: { color: 'yellow' } + }, + { + when: { greaterThan: 70 }, + apply: { color: 'red' } + } + ] + }; + + const result = applyRules(item, mockContext, [item]); + // Both match (80 > 50 and 80 > 70), but second overrides first (no stop flags) + expect(result.color).toBe('red'); + }); + + test('respects stop flag', () => { + const item = { + id: 'test', + type: 'context-percentage', + color: 'white', + rules: [ + { + when: { greaterThan: 50 }, + apply: { color: 'yellow' }, + stop: true + }, + { + when: { greaterThan: 70 }, + apply: { color: 'red' } + } + ] + }; + + const result = applyRules(item, mockContext, [item]); + // First rule matches and stops, second never evaluated + expect(result.color).toBe('yellow'); + }); + + test('applies multiple properties from one rule', () => { + const item = { + id: 'test', + type: 'context-percentage', + color: 'white', + bold: false, + rules: [ + { + when: { greaterThan: 75 }, + apply: { color: 'red', bold: true } + } + ] + }; + + const result = applyRules(item, mockContext, [item]); + expect(result.color).toBe('red'); + expect(result.bold).toBe(true); + }); + + test('handles widgets without numeric values', () => { + const item = { + id: 'test', + type: 'custom-text', // No numeric value + color: 'white', + rules: [ + { + when: { greaterThan: 50 }, + apply: { color: 'red' } + } + ] + }; + + const result = applyRules(item, mockContext, [item]); + // No numeric value, condition can't match + expect(result.color).toBe('white'); + }); + + test('all numeric operators work', () => { + const testCases = [ + { operator: 'greaterThan', value: 70, expected: true }, + { operator: 'greaterThan', value: 90, expected: false }, + { operator: 'greaterThanOrEqual', value: 80, expected: true }, + { operator: 'greaterThanOrEqual', value: 81, expected: false }, + { operator: 'lessThan', value: 90, expected: true }, + { operator: 'lessThan', value: 70, expected: false }, + { operator: 'lessThanOrEqual', value: 80, expected: true }, + { operator: 'lessThanOrEqual', value: 79, expected: false }, + { operator: 'equals', value: 80, expected: true }, + { operator: 'equals', value: 79, expected: false } + ]; + + for (const { operator, value, expected } of testCases) { + const item = { + id: 'test', + type: 'context-percentage', + color: 'white', + rules: [ + { + when: { [operator]: value }, + apply: { color: 'red' } + } + ] + }; + + const result = applyRules(item, mockContext, [item]); + expect(result.color).toBe(expected ? 'red' : 'white'); + } + }); + + test('handles empty rules array', () => { + const item = { + id: 'test', + type: 'context-percentage', + color: 'white', + rules: [] + }; + + const result = applyRules(item, mockContext, [item]); + expect(result).toEqual(item); + }); + + test('accumulates properties from multiple matching rules without stop', () => { + const item = { + id: 'test', + type: 'context-percentage', + color: 'white', + bold: false, + rules: [ + { + when: { greaterThan: 50 }, + apply: { color: 'yellow' } + }, + { + when: { greaterThan: 60 }, + apply: { bold: true } + }, + { + when: { greaterThan: 70 }, + apply: { color: 'red' } + } + ] + }; + + const result = applyRules(item, mockContext, [item]); + // All three match (80 > 50, 80 > 60, 80 > 70) + // Color: white → yellow → red (last override wins) + // Bold: false → true (only set once) + expect(result.color).toBe('red'); + expect(result.bold).toBe(true); + }); + + // Cross-widget condition tests + test('cross-widget condition: references other widget value', () => { + // Mock git commands to return 10 insertions + mockExecSync.mockReturnValueOnce('true\n'); + mockExecSync.mockReturnValueOnce('1 file changed, 10 insertions(+)'); + mockExecSync.mockReturnValueOnce(''); + + const gitChangesWidget = { + id: 'git-1', + type: 'git-changes', + color: 'white' + }; + + const contextWidget = { + id: 'context-1', + type: 'context-percentage', + color: 'white', + rules: [ + { + when: { widget: 'git-changes', greaterThan: 5 }, + apply: { color: 'red' } + } + ] + }; + + const result = applyRules(contextWidget, mockContext, [gitChangesWidget, contextWidget]); + expect(result.color).toBe('red'); + }); + + test('cross-widget condition: does not match when value too low', () => { + // Mock git commands to return 3 insertions (below threshold of 5) + mockExecSync.mockReturnValueOnce('true\n'); + mockExecSync.mockReturnValueOnce('1 file changed, 3 insertions(+)'); + mockExecSync.mockReturnValueOnce(''); + + const gitChangesWidget = { + id: 'git-1', + type: 'git-changes', + color: 'white' + }; + + const contextWidget = { + id: 'context-1', + type: 'context-percentage', + color: 'white', + rules: [ + { + when: { widget: 'git-changes', greaterThan: 5 }, + apply: { color: 'red' } + } + ] + }; + + const result = applyRules(contextWidget, mockContext, [gitChangesWidget, contextWidget]); + expect(result.color).toBe('white'); + }); + + test('cross-widget condition: fails gracefully when widget not found', () => { + const contextWidget = { + id: 'context-1', + type: 'context-percentage', + color: 'white', + rules: [ + { + when: { widget: 'git-changes', greaterThan: 5 }, + apply: { color: 'red' } + } + ] + }; + + // No git-changes widget in line, and no git data + // git-changes widget should return null for numeric value + const result = applyRules(contextWidget, mockContext, [contextWidget]); + expect(result.color).toBe('white'); // Rule doesn't match (no numeric value) + }); + + test('cross-widget condition: self reference works explicitly', () => { + const item = { + id: 'test', + type: 'context-percentage', + color: 'white', + rules: [ + { + when: { widget: 'self', greaterThan: 50 }, + apply: { color: 'yellow' } + } + ] + }; + + const result = applyRules(item, mockContext, [item]); + expect(result.color).toBe('yellow'); + }); + + test('cross-widget condition: implicit self reference (no widget property)', () => { + const item = { + id: 'test', + type: 'context-percentage', + color: 'white', + rules: [ + { + when: { greaterThan: 50 }, // No widget property + apply: { color: 'yellow' } + } + ] + }; + + const result = applyRules(item, mockContext, [item]); + expect(result.color).toBe('yellow'); + }); + + test('cross-widget condition: evaluates widget not in line', () => { + // Test that rules can reference ANY widget from catalog, not just widgets in the line + const item = { + id: 'test', + type: 'git-branch', + color: 'white', + rules: [ + { + // Reference tokens-input widget which is NOT in the line + when: { widget: 'tokens-input', greaterThan: 100000 }, + apply: { color: 'yellow' } + } + ] + }; + + // Note: tokens-input widget is NOT in allWidgetsInLine + // Rules engine should create temporary instance and evaluate it + // mockContext has total_input_tokens: 150000 which is > 100000 + const result = applyRules(item, mockContext, [item]); + expect(result.color).toBe('yellow'); + }); + + test('cross-widget condition: widget not in line returns false when no value', () => { + // Mock git command returning false (not in a git repo) + mockExecSync.mockReturnValueOnce('false\n'); + + const item = { + id: 'test', + type: 'git-branch', + color: 'white', + rules: [ + { + // Reference git-changes which is not in line AND has no value in context + when: { widget: 'git-changes', greaterThan: 5 }, + apply: { color: 'red' } + } + ] + }; + + // Context without git data - widget will render "(no git)" which parses to null + const result = applyRules(item, mockContext, [item]); + // Rule should not match (git-changes has no numeric value) + expect(result.color).toBe('white'); + }); + + // String operator tests + describe('String Operators', () => { + test('contains operator matches substring', () => { + // Mock git commands for branch name + mockExecSync.mockReturnValueOnce('true\n'); + mockExecSync.mockReturnValueOnce('feature/advanced-operators\n'); + + const item = { + id: 'test', + type: 'git-branch', + color: 'white', + rules: [ + { + when: { contains: 'feature' }, + apply: { color: 'green' } + } + ] + }; + + const result = applyRules(item, {}, [item]); + expect(result.color).toBe('green'); + }); + + test('contains operator does not match when substring absent', () => { + // Mock git commands for branch name + mockExecSync.mockReturnValueOnce('true\n'); + mockExecSync.mockReturnValueOnce('main\n'); + + const item = { + id: 'test', + type: 'git-branch', + color: 'white', + rules: [ + { + when: { contains: 'feature' }, + apply: { color: 'green' } + } + ] + }; + + const result = applyRules(item, {}, [item]); + expect(result.color).toBe('white'); + }); + + test('startsWith operator matches prefix', () => { + // Mock git commands for branch name + mockExecSync.mockReturnValueOnce('true\n'); + mockExecSync.mockReturnValueOnce('feature/test\n'); + + const item = { + id: 'test', + type: 'git-branch', + color: 'white', + rules: [ + { + when: { startsWith: 'feature/' }, + apply: { color: 'green' } + } + ] + }; + + const result = applyRules(item, {}, [item]); + expect(result.color).toBe('green'); + }); + + test('startsWith operator does not match wrong prefix', () => { + // Mock git commands for branch name + mockExecSync.mockReturnValueOnce('true\n'); + mockExecSync.mockReturnValueOnce('bugfix/test\n'); + + const item = { + id: 'test', + type: 'git-branch', + color: 'white', + rules: [ + { + when: { startsWith: 'feature/' }, + apply: { color: 'green' } + } + ] + }; + + const result = applyRules(item, {}, [item]); + expect(result.color).toBe('white'); + }); + + test('endsWith operator matches suffix', () => { + const context: RenderContext = { data: { model: { id: 'claude-opus-4-6' } } }; + + const item = { + id: 'test', + type: 'model', + color: 'white', + rules: [ + { + when: { endsWith: '4-6' }, + apply: { bold: true } + } + ] + }; + + const result = applyRules(item, context, [item]); + expect(result.bold).toBe(true); + }); + + test('endsWith operator does not match wrong suffix', () => { + const context: RenderContext = { data: { model: { id: 'claude-opus-4-6' } } }; + + const item = { + id: 'test', + type: 'model', + color: 'white', + rules: [ + { + when: { endsWith: 'haiku' }, + apply: { bold: true } + } + ] + }; + + const result = applyRules(item, context, [item]); + expect(result.bold).toBeUndefined(); + }); + + test('string operators fail on numeric widgets', () => { + const item = { + id: 'test', + type: 'context-percentage', + color: 'white', + rules: [ + { + when: { contains: '80' }, + apply: { color: 'red' } + } + ] + }; + + const result = applyRules(item, mockContext, [item]); + // Type mismatch - context-percentage returns number, not string + expect(result.color).toBe('white'); + }); + }); + + // Boolean operator tests + describe('Boolean Operators', () => { + test('isTrue operator matches when widget has changes (numeric to boolean coercion)', () => { + // Mock git commands to return changes + mockExecSync.mockReturnValueOnce('true\n'); + mockExecSync.mockReturnValueOnce('1 file changed, 10 insertions(+)'); + mockExecSync.mockReturnValueOnce(''); + + const item = { + id: 'test', + type: 'git-changes', + color: 'white', + rules: [ + { + when: { isTrue: true }, + apply: { color: 'red' } + } + ] + }; + + const result = applyRules(item, {}, [item]); + // git-changes returns 10 (numeric), which is coerced to true (non-zero = true) + expect(result.color).toBe('red'); + }); + + test('isTrue operator works with custom-text returning "true"', () => { + const item = { + id: 'test', + type: 'custom-text', + customText: 'true', + color: 'white', + rules: [ + { + when: { isTrue: true }, + apply: { color: 'green' } + } + ] + }; + + const result = applyRules(item, {}, [item]); + expect(result.color).toBe('green'); + }); + + test('isTrue operator does not match "false" string', () => { + const item = { + id: 'test', + type: 'custom-text', + customText: 'false', + color: 'white', + rules: [ + { + when: { isTrue: true }, + apply: { color: 'green' } + } + ] + }; + + const result = applyRules(item, {}, [item]); + expect(result.color).toBe('white'); + }); + + test('isTrue with false condition value matches false boolean', () => { + const item = { + id: 'test', + type: 'custom-text', + customText: 'false', + color: 'white', + rules: [ + { + when: { isTrue: false }, + apply: { color: 'green' } + } + ] + }; + + const result = applyRules(item, {}, [item]); + expect(result.color).toBe('green'); + }); + }); + + // Set operator tests + describe('Set Operators', () => { + test('in operator matches when value in array', () => { + // Mock git commands for branch name + mockExecSync.mockReturnValueOnce('true\n'); + mockExecSync.mockReturnValueOnce('main\n'); + + const item = { + id: 'test', + type: 'git-branch', + color: 'white', + rules: [ + { + when: { in: ['main', 'master', 'develop'] }, + apply: { color: 'cyan', bold: true } + } + ] + }; + + const result = applyRules(item, {}, [item]); + expect(result.color).toBe('cyan'); + expect(result.bold).toBe(true); + }); + + test('in operator does not match when value not in array', () => { + // Mock git commands for branch name + mockExecSync.mockReturnValueOnce('true\n'); + mockExecSync.mockReturnValueOnce('feature/test\n'); + + const item = { + id: 'test', + type: 'git-branch', + color: 'white', + rules: [ + { + when: { in: ['main', 'master', 'develop'] }, + apply: { color: 'cyan', bold: true } + } + ] + }; + + const result = applyRules(item, {}, [item]); + expect(result.color).toBe('white'); + expect(result.bold).toBeUndefined(); + }); + + test('notIn operator matches when value not in array', () => { + const context: RenderContext = { data: { model: { id: 'claude-opus-4-6' } } }; + + const item = { + id: 'test', + type: 'model', + color: 'white', + rules: [ + { + when: { notIn: ['claude-haiku-4-5-20251001'] }, + apply: { bold: true } + } + ] + }; + + const result = applyRules(item, context, [item]); + expect(result.bold).toBe(true); + }); + + test('notIn operator does not match when value in array', () => { + const context: RenderContext = { data: { model: { id: 'claude-opus-4-6' } } }; + + const item = { + id: 'test', + type: 'model', + color: 'white', + rules: [ + { + when: { notIn: ['claude-opus-4-6', 'claude-sonnet-4-6'] }, + apply: { bold: true } + } + ] + }; + + const result = applyRules(item, context, [item]); + expect(result.bold).toBeUndefined(); + }); + + test('in operator works with numeric values', () => { + const item = { + id: 'test', + type: 'context-percentage', + color: 'white', + rules: [ + { + when: { in: [60, 70, 80, 90] }, + apply: { color: 'yellow' } + } + ] + }; + + const result = applyRules(item, mockContext, [item]); + expect(result.color).toBe('yellow'); + }); + }); + + // Cross-widget with advanced operators + describe('Cross-Widget Advanced Operators', () => { + test('cross-widget string condition', () => { + // Mock git commands for branch name + mockExecSync.mockReturnValueOnce('true\n'); + mockExecSync.mockReturnValueOnce('urgent/fix-bug\n'); + + const item = { + id: 'context-1', + type: 'context-percentage', + color: 'white', + rules: [ + { + when: { widget: 'git-branch', contains: 'urgent' }, + apply: { color: 'red', bold: true } + } + ] + }; + + const result = applyRules(item, mockContext, [item]); + expect(result.color).toBe('red'); + expect(result.bold).toBe(true); + }); + + test('cross-widget set condition', () => { + // Mock git commands for branch name + mockExecSync.mockReturnValueOnce('true\n'); + mockExecSync.mockReturnValueOnce('main\n'); + + const item = { + id: 'test', + type: 'context-percentage', + color: 'white', + rules: [ + { + when: { widget: 'git-branch', in: ['main', 'master'] }, + apply: { character: '🔒' } + } + ] + }; + + const result = applyRules(item, {}, [item]); + expect(result.character).toBe('🔒'); + }); + }); + + // NOT flag tests + describe('NOT Flag (Negation)', () => { + test('NOT with numeric operator inverts result', () => { + const item = { + id: 'test', + type: 'context-percentage', + color: 'white', + rules: [ + { + when: { greaterThan: 90, not: true }, + apply: { color: 'green' } + } + ] + }; + + const result = applyRules(item, mockContext, [item]); + // Value is 80, which is NOT > 90, so condition matches + expect(result.color).toBe('green'); + }); + + test('NOT with numeric operator when false', () => { + const item = { + id: 'test', + type: 'context-percentage', + color: 'white', + rules: [ + { + when: { lessThan: 50, not: true }, + apply: { color: 'red' } + } + ] + }; + + const result = applyRules(item, mockContext, [item]); + // Value is 80, which is NOT < 50, so condition matches + expect(result.color).toBe('red'); + }); + + test('NOT with string operator (notContains)', () => { + // Mock git commands for branch name + mockExecSync.mockReturnValueOnce('true\n'); + mockExecSync.mockReturnValueOnce('main\n'); + + const item = { + id: 'test', + type: 'git-branch', + color: 'white', + rules: [ + { + when: { contains: 'feature/', not: true }, + apply: { color: 'cyan' } + } + ] + }; + + const result = applyRules(item, {}, [item]); + // Branch is "main" which does NOT contain "feature/", so condition matches + expect(result.color).toBe('cyan'); + }); + + test('NOT with startsWith operator', () => { + // Mock git commands for branch name + mockExecSync.mockReturnValueOnce('true\n'); + mockExecSync.mockReturnValueOnce('bugfix/test\n'); + + const item = { + id: 'test', + type: 'git-branch', + color: 'white', + rules: [ + { + when: { startsWith: 'feature/', not: true }, + apply: { color: 'yellow' } + } + ] + }; + + const result = applyRules(item, {}, [item]); + // Branch is "bugfix/test" which does NOT start with "feature/", so condition matches + expect(result.color).toBe('yellow'); + }); + + test('NOT with boolean operator', () => { + // Mock git commands to return no changes + mockExecSync.mockReturnValueOnce('true\n'); + mockExecSync.mockReturnValueOnce(''); + mockExecSync.mockReturnValueOnce(''); + + const item = { + id: 'test', + type: 'git-changes', + color: 'white', + rules: [ + { + when: { isTrue: true, not: true }, + apply: { color: 'green' } + } + ] + }; + + const result = applyRules(item, {}, [item]); + // No changes = 0 = false, NOT true = true matches, so condition matches + expect(result.color).toBe('green'); + }); + + test('NOT with set operator (notIn)', () => { + const context: RenderContext = { data: { model: { id: 'claude-opus-4-6' } } }; + + const item = { + id: 'test', + type: 'model', + color: 'white', + rules: [ + { + when: { in: ['claude-haiku-4-5-20251001', 'claude-sonnet-4-6'], not: true }, + apply: { bold: true } + } + ] + }; + + const result = applyRules(item, context, [item]); + // Model is NOT in the list, so condition matches + expect(result.bold).toBe(true); + }); + + test('NOT flag with cross-widget condition', () => { + // Mock git commands for branch name + mockExecSync.mockReturnValueOnce('true\n'); + mockExecSync.mockReturnValueOnce('develop\n'); + + const item = { + id: 'context-1', + type: 'context-percentage', + color: 'white', + rules: [ + { + when: { widget: 'git-branch', in: ['main', 'master'], not: true }, + apply: { color: 'yellow' } + } + ] + }; + + const result = applyRules(item, mockContext, [item]); + // Branch is "develop" which is NOT in ["main", "master"], so condition matches + expect(result.color).toBe('yellow'); + }); + + test('Multiple rules with mixed NOT flags', () => { + const item = { + id: 'test', + type: 'context-percentage', + color: 'white', + rules: [ + { + when: { greaterThan: 90, not: true }, + apply: { color: 'green' } + }, + { + when: { greaterThan: 70 }, + apply: { bold: true } + } + ] + }; + + const result = applyRules(item, mockContext, [item]); + // First rule: 80 is NOT > 90 = matches, applies green + // Second rule: 80 > 70 = matches, applies bold + expect(result.color).toBe('green'); + expect(result.bold).toBe(true); + }); + }); +}); \ No newline at end of file diff --git a/src/utils/__tests__/widget-values.test.ts b/src/utils/__tests__/widget-values.test.ts new file mode 100644 index 00000000..0717140c --- /dev/null +++ b/src/utils/__tests__/widget-values.test.ts @@ -0,0 +1,338 @@ +import { execSync } from 'child_process'; +import { + beforeEach, + describe, + expect, + test, + vi +} from 'vitest'; + +import type { RenderContext } from '../../types/RenderContext'; +import { clearGitCache } from '../git'; +import { + getWidgetBooleanValue, + getWidgetNumericValue, + getWidgetStringValue, + getWidgetValue, + supportsNumericValue +} from '../widget-values'; + +// Mock child_process +vi.mock('child_process', () => ({ execSync: vi.fn() })); + +const mockExecSync = execSync as unknown as { + mockReturnValue: (value: string) => void; + mockReturnValueOnce: (value: string) => void; + mockImplementation: (impl: () => never) => void; +}; + +// Mock git-remote module +const mockGetForkStatus = vi.fn(); +vi.mock('../git-remote', () => ({ getForkStatus: mockGetForkStatus })); + +describe('Widget Values', () => { + beforeEach(() => { + vi.clearAllMocks(); + clearGitCache(); + }); + + test('extracts context-percentage value', () => { + const context: RenderContext = { + data: { + context_window: { + context_window_size: 200000, + total_input_tokens: 150000, + total_output_tokens: 10000, + current_usage: null, + used_percentage: 80, + remaining_percentage: 20 + } + } + }; + + const value = getWidgetNumericValue('context-percentage', context, { id: 'test', type: 'context-percentage' }); + expect(value).toBe(80); + }); + + test('extracts git-changes value', () => { + // Mock git commands: is-inside-work-tree, unstaged diff, staged diff + mockExecSync.mockReturnValueOnce('true\n'); + mockExecSync.mockReturnValueOnce('1 file changed, 100 insertions(+)'); + mockExecSync.mockReturnValueOnce(''); + + const context: RenderContext = {}; + + const value = getWidgetNumericValue('git-changes', context, { id: 'test', type: 'git-changes' }); + expect(value).toBe(100); // git-changes returns insertions count + }); + + test('extracts git-insertions value', () => { + // Mock git commands: is-inside-work-tree, unstaged diff, staged diff + mockExecSync.mockReturnValueOnce('true\n'); + mockExecSync.mockReturnValueOnce('1 file changed, 100 insertions(+)'); + mockExecSync.mockReturnValueOnce(''); + + const context: RenderContext = {}; + + const value = getWidgetNumericValue('git-insertions', context, { id: 'test', type: 'git-insertions' }); + expect(value).toBe(100); + }); + + test('extracts git-deletions value', () => { + // Mock git commands: is-inside-work-tree, unstaged diff, staged diff + mockExecSync.mockReturnValueOnce('true\n'); + mockExecSync.mockReturnValueOnce(''); + mockExecSync.mockReturnValueOnce('1 file changed, 50 deletions(-)'); + + const context: RenderContext = {}; + + const value = getWidgetNumericValue('git-deletions', context, { id: 'test', type: 'git-deletions' }); + expect(value).toBe(-50); // Widget renders "-50", parses to negative number + }); + + test('extracts git-is-fork value when repo is a fork', () => { + const context: RenderContext = {}; + + // Mock getForkStatus to return fork status + mockGetForkStatus.mockReturnValueOnce({ + isFork: true, + origin: null, + upstream: null + }); + + const value = getWidgetNumericValue('git-is-fork', context, { id: 'test', type: 'git-is-fork' }); + expect(value).toBe(1); // Boolean true converted to 1 + }); + + test('extracts git-is-fork value when repo is not a fork', () => { + const context: RenderContext = {}; + + // Mock getForkStatus to return non-fork status + mockGetForkStatus.mockReturnValueOnce({ + isFork: false, + origin: null, + upstream: null + }); + + const value = getWidgetNumericValue('git-is-fork', context, { id: 'test', type: 'git-is-fork' }); + expect(value).toBe(0); // Boolean false converted to 0 + }); + + test('extracts tokens-input value', () => { + const context: RenderContext = { + data: { + context_window: { + context_window_size: 200000, + total_input_tokens: 150000, + total_output_tokens: 10000, + current_usage: null, + used_percentage: null, + remaining_percentage: null + } + } + }; + + const value = getWidgetNumericValue('tokens-input', context, { id: 'test', type: 'tokens-input' }); + expect(value).toBe(150000); + }); + + test('extracts tokens-output value', () => { + const context: RenderContext = { + data: { + context_window: { + context_window_size: 200000, + total_input_tokens: 150000, + total_output_tokens: 10000, + current_usage: null, + used_percentage: null, + remaining_percentage: null + } + } + }; + + const value = getWidgetNumericValue('tokens-output', context, { id: 'test', type: 'tokens-output' }); + expect(value).toBe(10000); + }); + + test('extracts tokens-total value', () => { + const context: RenderContext = { + tokenMetrics: { + inputTokens: 150000, + outputTokens: 10000, + cachedTokens: 0, + totalTokens: 160000, + contextLength: 160000 + } + }; + + const value = getWidgetNumericValue('tokens-total', context, { id: 'test', type: 'tokens-total' }); + expect(value).toBe(160000); + }); + + test('extracts session-cost value', () => { + const context: RenderContext = { data: { cost: { total_cost_usd: 0.45 } } }; + + const value = getWidgetNumericValue('session-cost', context, { id: 'test', type: 'session-cost' }); + expect(value).toBe(0.45); + }); + + test('returns null for unsupported widget types', () => { + const value = getWidgetNumericValue('custom-text', {}, { id: 'test', type: 'custom-text' }); + expect(value).toBeNull(); + }); + + test('returns null when data is missing', () => { + // Mock git command returning false (not in a git repo) + mockExecSync.mockReturnValueOnce('false\n'); + + const value = getWidgetNumericValue('git-changes', {}, { id: 'test', type: 'git-changes' }); + expect(value).toBeNull(); // "(no git)" should parse to null + }); + + test('returns null when context-percentage data missing', () => { + const context: RenderContext = { data: {} }; + const value = getWidgetNumericValue('context-percentage', context, { id: 'test', type: 'context-percentage' }); + expect(value).toBeNull(); + }); + + test('supportsNumericValue identifies supported widgets', () => { + expect(supportsNumericValue('context-percentage')).toBe(true); + expect(supportsNumericValue('context-percentage-usable')).toBe(true); + expect(supportsNumericValue('git-changes')).toBe(true); + expect(supportsNumericValue('git-insertions')).toBe(true); + expect(supportsNumericValue('git-deletions')).toBe(true); + expect(supportsNumericValue('tokens-input')).toBe(true); + expect(supportsNumericValue('tokens-output')).toBe(true); + expect(supportsNumericValue('tokens-cached')).toBe(true); + expect(supportsNumericValue('tokens-total')).toBe(true); + expect(supportsNumericValue('session-cost')).toBe(true); + expect(supportsNumericValue('custom-text')).toBe(true); // All widgets support numeric value extraction (may return null) + expect(supportsNumericValue('unknown-widget')).toBe(false); + }); + + describe('String Value Extraction', () => { + test('extracts git-branch string value', () => { + // Mock git commands: is-inside-work-tree, branch name + mockExecSync.mockReturnValueOnce('true\n'); + mockExecSync.mockReturnValueOnce('feature/advanced-operators\n'); + + const context: RenderContext = {}; + const value = getWidgetStringValue('git-branch', context, { id: 'test', type: 'git-branch' }); + expect(value).toBe('feature/advanced-operators'); + }); + + test('extracts model string value', () => { + const context: RenderContext = { data: { model: { id: 'claude-opus-4-6' } } }; + + const value = getWidgetStringValue('model', context, { id: 'test', type: 'model' }); + expect(value).toBe('claude-opus-4-6'); + }); + + test('returns null for empty output', () => { + const value = getWidgetStringValue('custom-text', {}, { id: 'test', type: 'custom-text' }); + expect(value).toBeNull(); + }); + }); + + describe('Boolean Value Extraction', () => { + test('extracts git-changes boolean value (has changes)', () => { + // Mock git commands: is-inside-work-tree, unstaged diff, staged diff + mockExecSync.mockReturnValueOnce('true\n'); + mockExecSync.mockReturnValueOnce('1 file changed, 100 insertions(+)'); + mockExecSync.mockReturnValueOnce(''); + + const context: RenderContext = {}; + const value = getWidgetBooleanValue('git-changes', context, { id: 'test', type: 'git-changes' }); + expect(value).toBe(true); + }); + + test('extracts git-changes boolean value (no changes)', () => { + // Mock git commands: is-inside-work-tree, unstaged diff, staged diff + mockExecSync.mockReturnValueOnce('true\n'); + mockExecSync.mockReturnValueOnce(''); + mockExecSync.mockReturnValueOnce(''); + + const context: RenderContext = {}; + const value = getWidgetBooleanValue('git-changes', context, { id: 'test', type: 'git-changes' }); + expect(value).toBe(false); + }); + + test('parses "true" string as boolean', () => { + const context: RenderContext = {}; + const value = getWidgetBooleanValue('custom-text', context, { + id: 'test', + type: 'custom-text', + customText: 'true' + }); + expect(value).toBe(true); + }); + + test('parses "false" string as boolean', () => { + const context: RenderContext = {}; + const value = getWidgetBooleanValue('custom-text', context, { + id: 'test', + type: 'custom-text', + customText: 'false' + }); + expect(value).toBe(false); + }); + + test('returns null for non-boolean strings', () => { + const context: RenderContext = { data: { model: { id: 'claude-opus-4-6' } } }; + const value = getWidgetBooleanValue('model', context, { id: 'test', type: 'model' }); + expect(value).toBeNull(); + }); + }); + + describe('Generic Value Extraction', () => { + test('prefers numeric value when available', () => { + const context: RenderContext = { + data: { + context_window: { + context_window_size: 200000, + total_input_tokens: 150000, + total_output_tokens: 10000, + current_usage: null, + used_percentage: 80, + remaining_percentage: 20 + } + } + }; + + const value = getWidgetValue('context-percentage', context, { id: 'test', type: 'context-percentage' }); + expect(value).toBe(80); + expect(typeof value).toBe('number'); + }); + + test('returns numeric value for git-changes', () => { + // Mock git commands: is-inside-work-tree, unstaged diff, staged diff + mockExecSync.mockReturnValueOnce('true\n'); + mockExecSync.mockReturnValueOnce('1 file changed, 100 insertions(+)'); + mockExecSync.mockReturnValueOnce(''); + + const context: RenderContext = {}; + const value = getWidgetValue('git-changes', context, { id: 'test', type: 'git-changes' }); + // git-changes primary type is numeric (insertions count) + // It can be used with boolean operators via type coercion + expect(typeof value).toBe('number'); + expect(value).toBe(100); + }); + + test('returns string when numeric and boolean not available', () => { + // Mock git commands: git rev-parse --is-inside-work-tree, git branch --show-current + // Git commands are cached, so only mocked once even though widget renders multiple times + mockExecSync.mockReturnValueOnce('true\n'); + mockExecSync.mockReturnValueOnce('main\n'); + + const context: RenderContext = {}; + const value = getWidgetValue('git-branch', context, { id: 'test', type: 'git-branch' }); + expect(value).toBe('main'); + expect(typeof value).toBe('string'); + }); + + test('returns null when no value available', () => { + const value = getWidgetValue('custom-text', {}, { id: 'test', type: 'custom-text' }); + expect(value).toBeNull(); + }); + }); +}); \ No newline at end of file diff --git a/src/utils/input-guards.ts b/src/utils/input-guards.ts index 9da22726..441d73f5 100644 --- a/src/utils/input-guards.ts +++ b/src/utils/input-guards.ts @@ -5,6 +5,17 @@ export interface InputKeyLike { tab?: boolean; } +export interface InputKey extends InputKeyLike { + upArrow?: boolean; + downArrow?: boolean; + leftArrow?: boolean; + rightArrow?: boolean; + return?: boolean; + escape?: boolean; + backspace?: boolean; + delete?: boolean; +} + const CONTROL_CHAR_REGEX = /[\u0000-\u001F\u007F]/u; export const shouldInsertInput = (input: string, key: InputKeyLike): boolean => { diff --git a/src/utils/renderer.ts b/src/utils/renderer.ts index 42815ab5..94ba82ea 100644 --- a/src/utils/renderer.ts +++ b/src/utils/renderer.ts @@ -19,6 +19,7 @@ import { getPowerlineTheme } from './colors'; import { calculateContextPercentage } from './context-percentage'; +import { applyRules } from './rules-engine'; import { getTerminalWidth } from './terminal'; import { getWidget } from './widgets'; @@ -165,13 +166,19 @@ function renderPowerlineStatusLine( } if (widgetText) { + // Use widget from preRendered (has rules applied) instead of original widget + const widgetWithRules = preRendered?.widget ?? widget; + const effectiveColor = widgetWithRules.color ?? defaultColor; + + // Update widget color for use in powerline rendering (use widgetWithRules not widget!) + const coloredWidget = { ...widgetWithRules, color: effectiveColor }; // Apply default padding from settings const padding = settings.defaultPadding ?? ''; // If override FG color is set and this is a custom command with preserveColors, // we need to strip the ANSI codes from the widget text if (settings.overrideForegroundColor && settings.overrideForegroundColor !== 'none' - && widget.type === 'custom-command' && widget.preserveColors) { + && coloredWidget.type === 'custom-command' && coloredWidget.preserveColors) { // Strip ANSI color codes when override is active widgetText = stripSgrCodes(widgetText); } @@ -180,19 +187,19 @@ function renderPowerlineStatusLine( const prevItem = i > 0 ? filteredWidgets[i - 1] : null; const nextItem = i < filteredWidgets.length - 1 ? filteredWidgets[i + 1] : null; const omitLeadingPadding = prevItem?.merge === 'no-padding'; - const omitTrailingPadding = widget.merge === 'no-padding' && nextItem; + const omitTrailingPadding = coloredWidget.merge === 'no-padding' && nextItem; const leadingPadding = omitLeadingPadding ? '' : padding; const trailingPadding = omitTrailingPadding ? '' : padding; const paddedText = `${leadingPadding}${widgetText}${trailingPadding}`; - // Determine colors - let fgColor = widget.color ?? defaultColor; - let bgColor = widget.backgroundColor; + // Determine colors - use effectiveColor from threshold resolution + let fgColor = effectiveColor; + let bgColor = coloredWidget.backgroundColor; // Apply theme colors if a theme is set (and not 'custom') // For custom commands with preserveColors, only skip foreground theme colors - const skipFgTheme = widget.type === 'custom-command' && widget.preserveColors; + const skipFgTheme = coloredWidget.type === 'custom-command' && coloredWidget.preserveColors; if (themeColors) { if (!skipFgTheme) { @@ -202,7 +209,7 @@ function renderPowerlineStatusLine( // Only increment color index if this widget is not merged with the next one // This ensures merged widgets share the same color - if (!widget.merge) { + if (!coloredWidget.merge) { widgetColorIndex++; } } @@ -216,7 +223,7 @@ function renderPowerlineStatusLine( content: paddedText, bgColor: bgColor ?? undefined, // Make sure undefined, not empty string fgColor: fgColor, - widget: widget + widget: coloredWidget }); } } @@ -513,7 +520,16 @@ export function preRenderAllWidgets( continue; } - const widgetText = widgetImpl.render(widget, context, settings) ?? ''; + // Apply rules to widget properties before rendering + // Pass all widgets in the line for cross-widget condition evaluation + const widgetWithRules = applyRules(widget, context, lineWidgets); + + // Skip rendering if widget is hidden + if (widgetWithRules.hide) { + continue; + } + + const widgetText = widgetImpl.render(widgetWithRules, context, settings) ?? ''; // Store the rendered content without padding (padding is applied later) // Use stringWidth to properly calculate Unicode character display width @@ -521,7 +537,7 @@ export function preRenderAllWidgets( preRenderedLine.push({ content: widgetText, plainLength, - widget + widget: widgetWithRules // Use widget with rules applied! }); } @@ -682,9 +698,11 @@ export function renderStatusLine( if (settings.inheritSeparatorColors && i > 0 && !widget.color && !widget.backgroundColor) { // Only inherit if the separator doesn't have explicit colors set - const prevWidget = widgets[i - 1]; + // Use preRendered widget which has rules applied for accurate colors + const prevPreRendered = preRenderedWidgets[i - 1]; + const prevWidget = prevPreRendered?.widget ?? widgets[i - 1]; if (prevWidget && prevWidget.type !== 'separator' && prevWidget.type !== 'flex-separator') { - // Get the previous widget's colors + // Get the previous widget's colors (with rules applied) let widgetColor = prevWidget.color; if (!widgetColor) { const widgetImpl = getWidget(prevWidget.type); @@ -723,24 +741,28 @@ export function renderStatusLine( } if (widgetText) { + // Use widget from preRendered which has rules applied (colors, bold, etc.) + const effectiveWidget = preRendered?.widget ?? widget; + const effectiveColor = effectiveWidget.color ?? defaultColor; + // Special handling for custom-command with preserveColors - if (widget.type === 'custom-command' && widget.preserveColors) { + if (widget.type === 'custom-command' && effectiveWidget.preserveColors) { // Handle max width truncation for commands with ANSI codes let finalOutput = widgetText; - if (widget.maxWidth && widget.maxWidth > 0) { + if (effectiveWidget.maxWidth && effectiveWidget.maxWidth > 0) { const plainLength = getVisibleWidth(widgetText); - if (plainLength > widget.maxWidth) { - finalOutput = truncateStyledText(widgetText, widget.maxWidth, { ellipsis: false }); + if (plainLength > effectiveWidget.maxWidth) { + finalOutput = truncateStyledText(widgetText, effectiveWidget.maxWidth, { ellipsis: false }); } } // Preserve original colors from command output - elements.push({ content: finalOutput, type: widget.type, widget }); + elements.push({ content: finalOutput, type: widget.type, widget: effectiveWidget }); } else { - // Normal widget rendering with colors + // Normal widget rendering with colors - use effectiveWidget which has rules applied elements.push({ - content: applyColorsWithOverride(widgetText, widget.color ?? defaultColor, widget.backgroundColor, widget.bold), + content: applyColorsWithOverride(widgetText, effectiveColor, effectiveWidget.backgroundColor, effectiveWidget.bold), type: widget.type, - widget + widget: effectiveWidget }); } } diff --git a/src/utils/rules-engine.ts b/src/utils/rules-engine.ts new file mode 100644 index 00000000..2d7ac2a5 --- /dev/null +++ b/src/utils/rules-engine.ts @@ -0,0 +1,238 @@ +import { + getConditionNot, + getConditionOperator, + getConditionValue, + getConditionWidget, + isBooleanOperator, + isNumericOperator, + isSetOperator, + isStringOperator, + type NumericOperator, + type SetOperator, + type StringOperator +} from '../types/Condition'; +import type { RenderContext } from '../types/RenderContext'; +import type { WidgetItem } from '../types/Widget'; + +import { mergeWidgetWithRuleApply } from './widget-properties'; +import { getWidgetValue } from './widget-values'; + +/** + * Evaluate a numeric condition against a value + */ +function evaluateNumericCondition( + operator: NumericOperator, + widgetValue: number, + conditionValue: number +): boolean { + switch (operator) { + case 'greaterThan': + return widgetValue > conditionValue; + case 'greaterThanOrEqual': + return widgetValue >= conditionValue; + case 'lessThan': + return widgetValue < conditionValue; + case 'lessThanOrEqual': + return widgetValue <= conditionValue; + case 'equals': + return widgetValue === conditionValue; + default: + return false; + } +} + +/** + * Evaluate a string condition against a value + */ +function evaluateStringCondition( + operator: StringOperator, + widgetValue: string, + conditionValue: string +): boolean { + switch (operator) { + case 'contains': + return widgetValue.includes(conditionValue); + case 'startsWith': + return widgetValue.startsWith(conditionValue); + case 'endsWith': + return widgetValue.endsWith(conditionValue); + default: + return false; + } +} + +/** + * Evaluate a boolean condition against a value + * Supports type coercion: numbers and strings can be treated as booleans + */ +function evaluateBooleanCondition( + widgetValue: boolean | number | string, + conditionValue: boolean +): boolean { + // Convert widget value to boolean + let boolValue: boolean; + if (typeof widgetValue === 'boolean') { + boolValue = widgetValue; + } else if (typeof widgetValue === 'number') { + // Standard number-to-boolean conversion: 0 = false, non-zero = true + boolValue = widgetValue !== 0; + } else if (typeof widgetValue === 'string') { + // Parse string "true"/"false" + const lower = widgetValue.toLowerCase(); + if (lower === 'true') { + boolValue = true; + } else if (lower === 'false') { + boolValue = false; + } else { + return false; // String that isn't "true"/"false" can't be evaluated as boolean + } + } else { + return false; + } + + return boolValue === conditionValue; +} + +/** + * Evaluate a set condition against a value + */ +function evaluateSetCondition( + operator: SetOperator, + widgetValue: string | number, + conditionValue: (string | number)[] +): boolean { + switch (operator) { + case 'in': + return conditionValue.includes(widgetValue); + case 'notIn': + return !conditionValue.includes(widgetValue); + default: + return false; + } +} + +/** + * Evaluate a single rule condition + * + * @param when - The condition to evaluate + * @param currentWidget - The widget that owns the rule + * @param allWidgetsInLine - All widgets in the current line (for cross-widget references) + * @param context - The render context + */ +function evaluateCondition( + when: Record, + currentWidget: WidgetItem, + allWidgetsInLine: WidgetItem[], + context: RenderContext +): boolean { + const widgetRef = getConditionWidget(when); + const operator = getConditionOperator(when); + const conditionValue = getConditionValue(when); + const notFlag = getConditionNot(when); + + if (!operator || conditionValue === null) { + return false; // Invalid condition + } + + // Determine which widget to evaluate + let targetWidget: WidgetItem; + if (widgetRef === 'self') { + targetWidget = currentWidget; + } else { + // Find first widget with matching type in the line + const widgetInLine = allWidgetsInLine.find(w => w.type === widgetRef); + + if (widgetInLine) { + targetWidget = widgetInLine; + } else { + // Widget not in line - create temporary instance for evaluation + // This allows rules to reference any widget from the catalog + targetWidget = { + id: 'temp-rule-eval', + type: widgetRef + }; + } + } + + // Get the target widget's value (generic - can be number, string, or boolean) + const widgetValue = getWidgetValue(targetWidget.type, context, targetWidget); + + if (widgetValue === null) { + return false; // Widget has no evaluable value + } + + // Route to appropriate evaluation function based on operator type + let result: boolean; + + if (isNumericOperator(operator)) { + if (typeof widgetValue !== 'number' || typeof conditionValue !== 'number') { + return false; // Type mismatch + } + result = evaluateNumericCondition(operator, widgetValue, conditionValue); + } else if (isStringOperator(operator)) { + if (typeof widgetValue !== 'string' || typeof conditionValue !== 'string') { + return false; // Type mismatch + } + result = evaluateStringCondition(operator, widgetValue, conditionValue); + } else if (isBooleanOperator(operator)) { + // Boolean operators support type coercion from numbers and strings + if (typeof conditionValue !== 'boolean') { + return false; // Invalid condition value + } + result = evaluateBooleanCondition(widgetValue, conditionValue); + } else if (isSetOperator(operator)) { + if (!Array.isArray(conditionValue)) { + return false; // Invalid condition value + } + if (typeof widgetValue !== 'string' && typeof widgetValue !== 'number') { + return false; // Type mismatch + } + result = evaluateSetCondition(operator, widgetValue, conditionValue); + } else { + return false; // Unknown operator type + } + + // Apply negation if specified + return notFlag ? !result : result; +} + +/** + * Apply rules to a widget and return merged properties + * + * Rules execute top-to-bottom. First matching rule with stop=true halts evaluation. + * Returns the widget item with rule overrides applied. + * + * @param item - The widget to apply rules to + * @param context - The render context + * @param allWidgetsInLine - All widgets in the current line (for cross-widget conditions) + */ +export function applyRules( + item: WidgetItem, + context: RenderContext, + allWidgetsInLine: WidgetItem[] +): WidgetItem { + const rules = item.rules; + if (!rules || rules.length === 0) { + return item; // No rules, return original + } + + // Build merged properties starting with base item + let mergedItem = { ...item }; + + // Evaluate rules top-to-bottom + for (const rule of rules) { + const matches = evaluateCondition(rule.when, item, allWidgetsInLine, context); + + if (matches) { + // Apply this rule's property overrides using deep merge for metadata + mergedItem = mergeWidgetWithRuleApply(mergedItem, rule.apply); + + // Stop if rule has stop flag + if (rule.stop) { + break; + } + } + } + + return mergedItem; +} \ No newline at end of file diff --git a/src/utils/widget-properties.ts b/src/utils/widget-properties.ts new file mode 100644 index 00000000..d0bae4c0 --- /dev/null +++ b/src/utils/widget-properties.ts @@ -0,0 +1,176 @@ +/** + * Shared widget property mutation utilities + * + * These functions handle the cycling/toggling of widget properties + * and are used by both ItemsEditor and RulesEditor to ensure consistent behavior. + */ + +import type { WidgetItem } from '../types/Widget'; + +function isStringRecord(value: unknown): value is Record { + return typeof value === 'object' + && value !== null + && Object.values(value).every(entry => typeof entry === 'string'); +} + +/** + * Merge base widget with rule.apply overrides, handling metadata deep merge + * + * IMPORTANT: This is the canonical way to merge widget + rule.apply. + * The shallow spread operator `{ ...widget, ...apply }` loses metadata! + * + * @param widget - Base widget + * @param apply - Rule.apply overrides + * @returns Merged widget with deep-merged metadata + */ +export function mergeWidgetWithRuleApply( + widget: WidgetItem, + apply: Record +): WidgetItem { + const merged = { ...widget, ...apply }; + const applyMetadata = isStringRecord(apply.metadata) ? apply.metadata : undefined; + + // Deep merge metadata to preserve both base widget and rule metadata + if (applyMetadata || widget.metadata) { + merged.metadata = { + ...(widget.metadata ?? {}), + ...(applyMetadata ?? {}) + }; + } + + return merged; +} + +/** + * Cycle merge state through: undefined → true → 'no-padding' → undefined + * + * This is the canonical merge cycling logic used throughout the app. + * + * @param currentMerge - Current merge state + * @returns Next merge state in the cycle + */ +export function cycleMergeState( + currentMerge: boolean | 'no-padding' | undefined +): boolean | 'no-padding' | undefined { + if (currentMerge === undefined) { + return true; + } else if (currentMerge === true) { + return 'no-padding'; + } else { + return undefined; + } +} + +/** + * Apply merge state to a widget, removing the property if undefined + * + * @param widget - Widget to update + * @param mergeState - New merge state (undefined removes the property) + * @returns Updated widget + */ +export function applyMergeState( + widget: WidgetItem, + mergeState: boolean | 'no-padding' | undefined +): WidgetItem { + if (mergeState === undefined) { + const { merge, ...rest } = widget; + void merge; // Intentionally unused + return rest; + } else { + return { ...widget, merge: mergeState }; + } +} + +/** + * Toggle merge state for a widget (used by ItemsEditor) + * + * @param widget - Widget to toggle + * @returns Updated widget + */ +export function toggleWidgetMerge(widget: WidgetItem): WidgetItem { + const nextMergeState = cycleMergeState(widget.merge); + return applyMergeState(widget, nextMergeState); +} + +/** + * Toggle raw value for a widget (used by ItemsEditor) + * + * @param widget - Widget to toggle + * @returns Updated widget + */ +export function toggleWidgetRawValue(widget: WidgetItem): WidgetItem { + return { ...widget, rawValue: !widget.rawValue }; +} + +/** + * Extract property overrides for rule.apply by comparing updated widget to base widget + * + * Used by RulesEditor to determine what should be in rule.apply after widget modifications. + * Only properties that differ from the base widget are included. + * Properties that match the base are removed from the existing apply object. + * + * @param updatedWidget - Widget after modifications + * @param baseWidget - Original widget before modifications + * @param currentApply - Existing rule.apply object to update + * @returns New apply object with only differing properties + */ +/** + * Deep equality check for comparing values + */ +function isEqual(a: unknown, b: unknown): boolean { + if (a === b) + return true; + if (a === null || a === undefined || b === null || b === undefined) + return false; + if (typeof a !== typeof b) + return false; + + // For objects, do deep comparison + if (typeof a === 'object' && typeof b === 'object') { + const aObj = a as Record; + const bObj = b as Record; + + const aKeys = Object.keys(aObj); + const bKeys = Object.keys(bObj); + + if (aKeys.length !== bKeys.length) + return false; + + return aKeys.every(key => isEqual(aObj[key], bObj[key])); + } + + return false; +} + +export function extractWidgetOverrides( + updatedWidget: WidgetItem, + baseWidget: WidgetItem, + currentApply: Record +): Record { + const newApply: Record = {}; + + // Get all keys we need to check: from updatedWidget, baseWidget, and currentApply + const allKeys = new Set([ + ...Object.keys(updatedWidget), + ...Object.keys(baseWidget), + ...Object.keys(currentApply) + ]); + + allKeys.forEach((key) => { + // Skip structural properties that shouldn't be in rule.apply + if (key === 'id' || key === 'type' || key === 'rules') { + return; + } + + const updatedValue = (updatedWidget as Record)[key]; + const baseValue = (baseWidget as Record)[key]; + + // If different from widget base, add to rule.apply (use deep equality) + if (!isEqual(updatedValue, baseValue)) { + newApply[key] = updatedValue; + } + // Otherwise, it matches base, so don't include in apply + }); + + return newApply; +} \ No newline at end of file diff --git a/src/utils/widget-values.ts b/src/utils/widget-values.ts new file mode 100644 index 00000000..0cf3a396 --- /dev/null +++ b/src/utils/widget-values.ts @@ -0,0 +1,220 @@ +import type { RenderContext } from '../types/RenderContext'; +import { DEFAULT_SETTINGS } from '../types/Settings'; +import type { WidgetItem } from '../types/Widget'; + +import { getWidget } from './widgets'; + +/** + * Parse a rendered string to extract numeric value + * Handles: + * 1. Numeric formats: "80%", "150K", "1.5M", "42", "Ctx: 80%", "(+42,-10)" + * 2. Boolean strings: "true" → 1, "false" → 0 + * 3. Empty/null/unparseable: null, "", "(no git)" → null + * + * Does NOT parse strings that are primarily non-numeric: + * - "claude-opus-4-6" → null (identifier, not a number) + * - "main" → null (text, not a number) + * - "feature/test" → null (text, not a number) + */ +function parseNumericValue(text: string | null): number | null { + // Handle null/empty + if (!text) + return null; + + const cleaned = text.trim(); + if (!cleaned) + return null; + + // Try boolean strings first (case-insensitive) + const lower = cleaned.toLowerCase(); + if (lower === 'true') + return 1; + if (lower === 'false') + return 0; + + // Try to extract number with optional suffix (anywhere in string) + // Allow commas/underscores within the number (for formatting), but not at the end + const match = /([+-]?[\d,._]*\d)\s*([KMB%])?/i.exec(cleaned); + if (!match) { + // Not a number, not a boolean - unparseable + return null; + } + + const [fullMatch, numStr, suffix] = match; + if (!numStr) + return null; + + // Check if this looks like an identifier rather than a numeric value + // If there are word characters (letters/numbers) directly adjacent to the matched number + // without clear separators, it's likely an identifier like "opus-4-6" or "v1.2.3" + const beforeMatch = cleaned.substring(0, match.index); + const afterMatch = cleaned.substring(match.index + fullMatch.length); + + // If there are letters immediately before the number (without separator), skip it + // Exception: Allow common prefixes like "Ctx:" or "Cost:" + if (beforeMatch && /[a-zA-Z]$/.test(beforeMatch)) { + // Check if it's a common label pattern (ends with space, colon, or parenthesis) + if (!/[:(\s]$/.test(beforeMatch)) { + return null; + } + } + + // If there are letters or more digits immediately after (without separator), skip it + // This catches "opus-4-6" where we match "4" but there's "-6" after + if (afterMatch && /^[a-zA-Z\d-]/.test(afterMatch)) { + // Exception: Allow trailing closing parenthesis or comma (common in formatted output) + if (!/^[),\s]/.test(afterMatch)) { + return null; + } + } + + // Parse base number (remove commas, handle dots/underscores) + const baseNum = parseFloat(numStr.replace(/[,_]/g, '')); + if (isNaN(baseNum)) + return null; + + // Apply multiplier based on suffix + switch (suffix?.toUpperCase()) { + case 'K': + return baseNum * 1000; + case 'M': + return baseNum * 1000000; + case 'B': + return baseNum * 1000000000; + case '%': + return baseNum; // Keep percentage as-is (80% → 80) + default: + return baseNum; + } +} + +/** + * Extract numeric value from a widget for condition evaluation + * + * Strategy: + * 1. If widget implements getNumericValue(), use that (for precision) + * 2. Otherwise, render the widget and parse the string (default behavior) + */ +export function getWidgetNumericValue( + widgetType: string, + context: RenderContext, + item: WidgetItem +): number | null { + const widget = getWidget(widgetType); + if (!widget) + return null; + + // Try explicit numeric value method first + if (widget.getNumericValue) { + return widget.getNumericValue(context, item); + } + + // Default: render and parse + // Use raw value mode to get numeric data without labels + const rawItem = { ...item, rawValue: true }; + const rendered = widget.render(rawItem, context, DEFAULT_SETTINGS); + const parsed = parseNumericValue(rendered); + + // Debug logging + if (process.env.DEBUG_RULES === 'true') { + console.log(`Widget ${widgetType}: rendered="${rendered}" parsed=${parsed}`); + } + + return parsed; +} + +/** + * Extract string value from a widget for condition evaluation + * + * Renders the widget in raw value mode and returns the text output + */ +export function getWidgetStringValue( + widgetType: string, + context: RenderContext, + item: WidgetItem +): string | null { + const widget = getWidget(widgetType); + if (!widget) + return null; + + // Render in raw value mode to get clean text without labels + const rawItem = { ...item, rawValue: true }; + const rendered = widget.render(rawItem, context, DEFAULT_SETTINGS); + + // Return rendered text (null if empty) + return rendered && rendered.trim() !== '' ? rendered.trim() : null; +} + +/** + * Extract boolean value from a widget for condition evaluation + * + * Handles known boolean widgets and parses boolean strings + */ +export function getWidgetBooleanValue( + widgetType: string, + context: RenderContext, + item: WidgetItem +): boolean | null { + const widget = getWidget(widgetType); + if (!widget) + return null; + + // For git-changes, check if there are any changes + if (widgetType === 'git-changes') { + // Get numeric value and check if > 0 + const numValue = getWidgetNumericValue(widgetType, context, item); + if (numValue !== null) { + return numValue > 0; + } + return null; + } + + // Try to parse as boolean from rendered output + const rawItem = { ...item, rawValue: true }; + const rendered = widget.render(rawItem, context, DEFAULT_SETTINGS); + if (!rendered) + return null; + + const cleaned = rendered.trim().toLowerCase(); + if (cleaned === 'true') + return true; + if (cleaned === 'false') + return false; + + // Not a boolean value + return null; +} + +/** + * Generic value extraction - returns the widget's primary value type + * + * This is the main entry point for rule condition evaluation. + * Priority order: + * 1. Numeric - for numbers, percentages, counts (most common) + * 2. String - for text values + * + * Note: Boolean conversion happens in condition evaluation, not here. + * Numbers can be treated as booleans (0=false, non-zero=true) by the evaluator. + */ +export function getWidgetValue( + widgetType: string, + context: RenderContext, + item: WidgetItem +): number | string | boolean | null { + // Try numeric first (most widgets have numeric values) + const numericValue = getWidgetNumericValue(widgetType, context, item); + if (numericValue !== null) + return numericValue; + + // Fall back to string + return getWidgetStringValue(widgetType, context, item); +} + +/** + * Check if a widget type supports numeric value extraction + */ +export function supportsNumericValue(widgetType: string): boolean { + const widget = getWidget(widgetType); + // All widgets support it via default parsing behavior + return !!widget; +} \ No newline at end of file From f89076be6447891cb4c1c1919d832f96c9fb780b Mon Sep 17 00:00:00 2001 From: Mark O'Keefe Date: Mon, 23 Mar 2026 16:45:19 +1100 Subject: [PATCH 03/42] fix: correct git status detection for unstaged changes and merge conflicts Fixed three issues in git status parsing: 1. Unstaged pattern was incomplete - only detected M/D, missed merge conflicts (UU, AU, DU, AA, UA, UD) and other status codes (R, C, T) 2. Added -z flag for NUL-terminated output to properly handle filenames with special characters 3. Changed trim() to trimEnd() to preserve significant leading spaces in git porcelain format (e.g., ' M file.txt' for unstaged modifications) Added comprehensive test coverage with 20 new test cases covering all merge conflict scenarios, rename/copy/type-change detection, and edge cases. Co-Authored-By: Claude Sonnet 4.5 --- src/utils/__tests__/git.test.ts | 165 ++++++++++++++++++++++++++++++-- src/utils/git.ts | 8 +- 2 files changed, 163 insertions(+), 10 deletions(-) diff --git a/src/utils/__tests__/git.test.ts b/src/utils/__tests__/git.test.ts index 207e735e..af77df85 100644 --- a/src/utils/__tests__/git.test.ts +++ b/src/utils/__tests__/git.test.ts @@ -11,6 +11,7 @@ import type { RenderContext } from '../../types/RenderContext'; import { clearGitCache, getGitChangeCounts, + getGitStatus, isInsideGitWorkTree, resolveGitCwd, runGit @@ -21,7 +22,7 @@ vi.mock('child_process', () => ({ execSync: vi.fn() })); const mockExecSync = execSync as unknown as { mock: { calls: unknown[][] }; mockImplementation: (impl: () => never) => void; - mockReturnValue: (value: string) => void; + mockReturnValueOnce: (value: string) => void; mockReturnValueOnce: (value: string) => void; }; @@ -85,8 +86,8 @@ describe('git utils', () => { }); describe('runGit', () => { - it('runs git command with resolved cwd and trims output', () => { - mockExecSync.mockReturnValue(' feature/worktree \n'); + it('runs git command with resolved cwd and trims trailing whitespace', () => { + mockExecSync.mockReturnValueOnce('feature/worktree\n'); const context: RenderContext = { data: { cwd: '/tmp/repo' } }; const result = runGit('branch --show-current', context); @@ -101,7 +102,7 @@ describe('git utils', () => { }); it('runs git command without cwd when no context directory exists', () => { - mockExecSync.mockReturnValue('true\n'); + mockExecSync.mockReturnValueOnce('true\n'); const result = runGit('rev-parse --is-inside-work-tree', {}); @@ -121,13 +122,13 @@ describe('git utils', () => { describe('isInsideGitWorkTree', () => { it('returns true when git reports true', () => { - mockExecSync.mockReturnValue('true\n'); + mockExecSync.mockReturnValueOnce('true\n'); expect(isInsideGitWorkTree({})).toBe(true); }); it('returns false when git reports false', () => { - mockExecSync.mockReturnValue('false\n'); + mockExecSync.mockReturnValueOnce('false\n'); expect(isInsideGitWorkTree({})).toBe(false); }); @@ -169,4 +170,156 @@ describe('git utils', () => { }); }); }); + + describe('getGitStatus', () => { + it('returns all false when no git output', () => { + mockExecSync.mockReturnValueOnce(''); + + expect(getGitStatus({})).toEqual({ + staged: false, + unstaged: false, + untracked: false + }); + }); + + it('detects staged modification', () => { + mockExecSync.mockReturnValueOnce('M file.txt'); + + const result = getGitStatus({}); + expect(result.staged).toBe(true); + expect(result.unstaged).toBe(false); + }); + + it('detects unstaged modification', () => { + mockExecSync.mockReturnValueOnce(' M file.txt'); + + const result = getGitStatus({}); + expect(result.staged).toBe(false); + expect(result.unstaged).toBe(true); + }); + + it('detects both staged and unstaged modification', () => { + mockExecSync.mockReturnValueOnce('MM file.txt'); + + const result = getGitStatus({}); + expect(result.staged).toBe(true); + expect(result.unstaged).toBe(true); + }); + + it('detects unstaged deletion', () => { + mockExecSync.mockReturnValueOnce(' D file.txt'); + + const result = getGitStatus({}); + expect(result.staged).toBe(false); + expect(result.unstaged).toBe(true); + }); + + it('detects staged deletion', () => { + mockExecSync.mockReturnValueOnce('D file.txt'); + + const result = getGitStatus({}); + expect(result.staged).toBe(true); + expect(result.unstaged).toBe(false); + }); + + it('detects untracked files', () => { + mockExecSync.mockReturnValueOnce('?? newfile.txt'); + + const result = getGitStatus({}); + expect(result.untracked).toBe(true); + expect(result.staged).toBe(false); + expect(result.unstaged).toBe(false); + }); + + it('detects merge conflict: both modified (UU)', () => { + mockExecSync.mockReturnValueOnce('UU file.txt'); + + const result = getGitStatus({}); + expect(result.staged).toBe(true); + expect(result.unstaged).toBe(true); + }); + + it('detects merge conflict: added by us (AU)', () => { + mockExecSync.mockReturnValueOnce('AU file.txt'); + + const result = getGitStatus({}); + expect(result.staged).toBe(true); + expect(result.unstaged).toBe(true); + }); + + it('detects merge conflict: deleted by us (DU)', () => { + mockExecSync.mockReturnValueOnce('DU file.txt'); + + const result = getGitStatus({}); + expect(result.staged).toBe(true); + expect(result.unstaged).toBe(true); + }); + + it('detects merge conflict: both added (AA)', () => { + mockExecSync.mockReturnValueOnce('AA file.txt'); + + const result = getGitStatus({}); + expect(result.staged).toBe(true); + expect(result.unstaged).toBe(true); + }); + + it('detects merge conflict: added by them (UA)', () => { + mockExecSync.mockReturnValueOnce('UA file.txt'); + + const result = getGitStatus({}); + expect(result.staged).toBe(true); + expect(result.unstaged).toBe(true); + }); + + it('detects merge conflict: deleted by them (UD)', () => { + mockExecSync.mockReturnValueOnce('UD file.txt'); + + const result = getGitStatus({}); + expect(result.staged).toBe(true); + expect(result.unstaged).toBe(true); + }); + + it('detects renamed file in index (staged)', () => { + mockExecSync.mockReturnValueOnce('R oldname.txt -> newname.txt'); + + const result = getGitStatus({}); + expect(result.staged).toBe(true); + expect(result.unstaged).toBe(false); + }); + + it('detects copied file in index (staged)', () => { + mockExecSync.mockReturnValueOnce('C original.txt -> copy.txt'); + + const result = getGitStatus({}); + expect(result.staged).toBe(true); + expect(result.unstaged).toBe(false); + }); + + it('detects type changed file in index (staged)', () => { + mockExecSync.mockReturnValueOnce('T file.txt'); + + const result = getGitStatus({}); + expect(result.staged).toBe(true); + expect(result.unstaged).toBe(false); + }); + + it('detects mixed status with multiple files', () => { + mockExecSync.mockReturnValueOnce('M staged.txt\0 M unstaged.txt\0?? untracked.txt'); + + const result = getGitStatus({}); + expect(result.staged).toBe(true); + expect(result.unstaged).toBe(true); + expect(result.untracked).toBe(true); + }); + + it('handles git command failure', () => { + mockExecSync.mockImplementation(() => { throw new Error('git failed'); }); + + expect(getGitStatus({})).toEqual({ + staged: false, + unstaged: false, + untracked: false + }); + }); + }); }); \ No newline at end of file diff --git a/src/utils/git.ts b/src/utils/git.ts index dca0aad2..f0bc6012 100644 --- a/src/utils/git.ts +++ b/src/utils/git.ts @@ -40,7 +40,7 @@ export function runGit(command: string, context: RenderContext): string | null { encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'], ...(cwd ? { cwd } : {}) - }).trim(); + }).trimEnd(); const result = output.length > 0 ? output : null; gitCommandCache.set(cacheKey, result); @@ -91,7 +91,7 @@ export interface GitStatus { } export function getGitStatus(context: RenderContext): GitStatus { - const output = runGit('--no-optional-locks status --porcelain', context); + const output = runGit('--no-optional-locks status --porcelain -z', context); if (!output) { return { staged: false, unstaged: false, untracked: false }; @@ -101,12 +101,12 @@ export function getGitStatus(context: RenderContext): GitStatus { let unstaged = false; let untracked = false; - for (const line of output.split('\n')) { + for (const line of output.split('\0')) { if (line.length < 2) continue; if (!staged && /^[MADRCTU]/.test(line)) staged = true; - if (!unstaged && /^.[MD]/.test(line)) + if (!unstaged && /^.[MADRCTU]/.test(line)) unstaged = true; if (!untracked && line.startsWith('??')) untracked = true; From ca724bb7bf19b0d43e76b16c7f7806ce8b05a911 Mon Sep 17 00:00:00 2001 From: Mark O'Keefe Date: Mon, 23 Mar 2026 16:49:48 +1100 Subject: [PATCH 04/42] feat: add conflict detection to git status widget Added '!' indicator to GitStatus widget to show merge conflicts with priority ordering: !+*? (conflicts, staged, unstaged, untracked). Conflicts are shown first as they're blocking - work cannot proceed until resolved. The priority ordering reflects urgency: blocking issues, intentional work, unsaved changes, then undecided files. Added conflict detection for all merge conflict states: DD, AU, UD, UA, DU, AA, UU Added test coverage for DD (both deleted) case and mixed status with conflicts Co-Authored-By: Claude Sonnet 4.5 --- src/utils/__tests__/git.test.ts | 41 +++++++++++++++++++++++-- src/utils/git.ts | 11 +++++-- src/widgets/GitStatus.ts | 10 +++--- src/widgets/__tests__/GitStatus.test.ts | 4 +++ 4 files changed, 57 insertions(+), 9 deletions(-) diff --git a/src/utils/__tests__/git.test.ts b/src/utils/__tests__/git.test.ts index af77df85..111d81bc 100644 --- a/src/utils/__tests__/git.test.ts +++ b/src/utils/__tests__/git.test.ts @@ -178,7 +178,8 @@ describe('git utils', () => { expect(getGitStatus({})).toEqual({ staged: false, unstaged: false, - untracked: false + untracked: false, + conflicts: false }); }); @@ -188,6 +189,7 @@ describe('git utils', () => { const result = getGitStatus({}); expect(result.staged).toBe(true); expect(result.unstaged).toBe(false); + expect(result.conflicts).toBe(false); }); it('detects unstaged modification', () => { @@ -196,6 +198,7 @@ describe('git utils', () => { const result = getGitStatus({}); expect(result.staged).toBe(false); expect(result.unstaged).toBe(true); + expect(result.conflicts).toBe(false); }); it('detects both staged and unstaged modification', () => { @@ -204,6 +207,7 @@ describe('git utils', () => { const result = getGitStatus({}); expect(result.staged).toBe(true); expect(result.unstaged).toBe(true); + expect(result.conflicts).toBe(false); }); it('detects unstaged deletion', () => { @@ -212,6 +216,7 @@ describe('git utils', () => { const result = getGitStatus({}); expect(result.staged).toBe(false); expect(result.unstaged).toBe(true); + expect(result.conflicts).toBe(false); }); it('detects staged deletion', () => { @@ -220,6 +225,7 @@ describe('git utils', () => { const result = getGitStatus({}); expect(result.staged).toBe(true); expect(result.unstaged).toBe(false); + expect(result.conflicts).toBe(false); }); it('detects untracked files', () => { @@ -229,12 +235,14 @@ describe('git utils', () => { expect(result.untracked).toBe(true); expect(result.staged).toBe(false); expect(result.unstaged).toBe(false); + expect(result.conflicts).toBe(false); }); it('detects merge conflict: both modified (UU)', () => { mockExecSync.mockReturnValueOnce('UU file.txt'); const result = getGitStatus({}); + expect(result.conflicts).toBe(true); expect(result.staged).toBe(true); expect(result.unstaged).toBe(true); }); @@ -243,6 +251,7 @@ describe('git utils', () => { mockExecSync.mockReturnValueOnce('AU file.txt'); const result = getGitStatus({}); + expect(result.conflicts).toBe(true); expect(result.staged).toBe(true); expect(result.unstaged).toBe(true); }); @@ -251,6 +260,7 @@ describe('git utils', () => { mockExecSync.mockReturnValueOnce('DU file.txt'); const result = getGitStatus({}); + expect(result.conflicts).toBe(true); expect(result.staged).toBe(true); expect(result.unstaged).toBe(true); }); @@ -259,6 +269,7 @@ describe('git utils', () => { mockExecSync.mockReturnValueOnce('AA file.txt'); const result = getGitStatus({}); + expect(result.conflicts).toBe(true); expect(result.staged).toBe(true); expect(result.unstaged).toBe(true); }); @@ -267,6 +278,7 @@ describe('git utils', () => { mockExecSync.mockReturnValueOnce('UA file.txt'); const result = getGitStatus({}); + expect(result.conflicts).toBe(true); expect(result.staged).toBe(true); expect(result.unstaged).toBe(true); }); @@ -275,6 +287,16 @@ describe('git utils', () => { mockExecSync.mockReturnValueOnce('UD file.txt'); const result = getGitStatus({}); + expect(result.conflicts).toBe(true); + expect(result.staged).toBe(true); + expect(result.unstaged).toBe(true); + }); + + it('detects merge conflict: both deleted (DD)', () => { + mockExecSync.mockReturnValueOnce('DD file.txt'); + + const result = getGitStatus({}); + expect(result.conflicts).toBe(true); expect(result.staged).toBe(true); expect(result.unstaged).toBe(true); }); @@ -285,6 +307,7 @@ describe('git utils', () => { const result = getGitStatus({}); expect(result.staged).toBe(true); expect(result.unstaged).toBe(false); + expect(result.conflicts).toBe(false); }); it('detects copied file in index (staged)', () => { @@ -293,6 +316,7 @@ describe('git utils', () => { const result = getGitStatus({}); expect(result.staged).toBe(true); expect(result.unstaged).toBe(false); + expect(result.conflicts).toBe(false); }); it('detects type changed file in index (staged)', () => { @@ -301,6 +325,7 @@ describe('git utils', () => { const result = getGitStatus({}); expect(result.staged).toBe(true); expect(result.unstaged).toBe(false); + expect(result.conflicts).toBe(false); }); it('detects mixed status with multiple files', () => { @@ -310,6 +335,17 @@ describe('git utils', () => { expect(result.staged).toBe(true); expect(result.unstaged).toBe(true); expect(result.untracked).toBe(true); + expect(result.conflicts).toBe(false); + }); + + it('detects mixed status with conflicts', () => { + mockExecSync.mockReturnValueOnce('UU conflict.txt\0M staged.txt\0 M unstaged.txt\0?? untracked.txt'); + + const result = getGitStatus({}); + expect(result.conflicts).toBe(true); + expect(result.staged).toBe(true); + expect(result.unstaged).toBe(true); + expect(result.untracked).toBe(true); }); it('handles git command failure', () => { @@ -318,7 +354,8 @@ describe('git utils', () => { expect(getGitStatus({})).toEqual({ staged: false, unstaged: false, - untracked: false + untracked: false, + conflicts: false }); }); }); diff --git a/src/utils/git.ts b/src/utils/git.ts index f0bc6012..d04c669a 100644 --- a/src/utils/git.ts +++ b/src/utils/git.ts @@ -88,33 +88,38 @@ export interface GitStatus { staged: boolean; unstaged: boolean; untracked: boolean; + conflicts: boolean; } export function getGitStatus(context: RenderContext): GitStatus { const output = runGit('--no-optional-locks status --porcelain -z', context); if (!output) { - return { staged: false, unstaged: false, untracked: false }; + return { staged: false, unstaged: false, untracked: false, conflicts: false }; } let staged = false; let unstaged = false; let untracked = false; + let conflicts = false; for (const line of output.split('\0')) { if (line.length < 2) continue; + // Conflict detection: DD, AU, UD, UA, DU, AA, UU + if (!conflicts && /^(DD|AU|UD|UA|DU|AA|UU)/.test(line)) + conflicts = true; if (!staged && /^[MADRCTU]/.test(line)) staged = true; if (!unstaged && /^.[MADRCTU]/.test(line)) unstaged = true; if (!untracked && line.startsWith('??')) untracked = true; - if (staged && unstaged && untracked) + if (staged && unstaged && untracked && conflicts) break; } - return { staged, unstaged, untracked }; + return { staged, unstaged, untracked, conflicts }; } export interface GitAheadBehind { diff --git a/src/widgets/GitStatus.ts b/src/widgets/GitStatus.ts index f9fe956c..dd562f87 100644 --- a/src/widgets/GitStatus.ts +++ b/src/widgets/GitStatus.ts @@ -21,7 +21,7 @@ import { export class GitStatusWidget implements Widget { getDefaultColor(): string { return 'yellow'; } - getDescription(): string { return 'Shows git status indicators: + staged, * unstaged, ? untracked'; } + getDescription(): string { return 'Shows git status indicators: + staged, * unstaged, ? untracked, ! conflicts'; } getDisplayName(): string { return 'Git Status'; } getCategory(): string { return 'Git'; } @@ -45,7 +45,7 @@ export class GitStatusWidget implements Widget { const hideNoGit = isHideNoGitEnabled(item); if (context.isPreview) { - return this.formatStatus(item, { staged: true, unstaged: true, untracked: false }); + return this.formatStatus(item, { staged: true, unstaged: true, untracked: false, conflicts: false }); } if (!isInsideGitWorkTree(context)) { @@ -55,15 +55,17 @@ export class GitStatusWidget implements Widget { const status = getGitStatus(context); // Hide if clean - if (!status.staged && !status.unstaged && !status.untracked) { + if (!status.staged && !status.unstaged && !status.untracked && !status.conflicts) { return null; } return this.formatStatus(item, status); } - private formatStatus(_item: WidgetItem, status: { staged: boolean; unstaged: boolean; untracked: boolean }): string { + private formatStatus(_item: WidgetItem, status: { staged: boolean; unstaged: boolean; untracked: boolean; conflicts: boolean }): string { const parts: string[] = []; + if (status.conflicts) + parts.push('!'); if (status.staged) parts.push('+'); if (status.unstaged) diff --git a/src/widgets/__tests__/GitStatus.test.ts b/src/widgets/__tests__/GitStatus.test.ts index 3906fa55..ec78e1ed 100644 --- a/src/widgets/__tests__/GitStatus.test.ts +++ b/src/widgets/__tests__/GitStatus.test.ts @@ -25,4 +25,8 @@ describe('GitStatusWidget', () => { expect(widget.getCategory()).toBe('Git'); expect(widget.supportsRawValue()).toBe(false); }); + + it('shows correct description including conflicts', () => { + expect(widget.getDescription()).toContain('! conflicts'); + }); }); \ No newline at end of file From b4149539d4b5c493962b1342d02e931524cd40a1 Mon Sep 17 00:00:00 2001 From: Mark O'Keefe Date: Mon, 23 Mar 2026 17:58:05 +1100 Subject: [PATCH 05/42] feat: add string equals/isEmpty operators and use text labels for all operators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add missing string operators (equals, not equals, isEmpty, notEmpty) to the rules engine. Replace symbol-based operator labels (>, ≥, <, ≤, =, ≠) with readable text labels (greater than, equals, etc.) for better usability. Reorder operator picker to pair each operator with its negation. Fix condition editor displaying wrong label for boolean false conditions. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/tui/components/ConditionEditor.tsx | 19 +-- src/tui/components/RulesEditor.tsx | 5 +- src/types/Condition.ts | 26 ++-- src/types/__tests__/Condition.test.ts | 19 ++- src/utils/__tests__/git.test.ts | 1 - src/utils/__tests__/rules-engine.test.ts | 170 +++++++++++++++++++++++ src/utils/rules-engine.ts | 21 ++- 7 files changed, 235 insertions(+), 26 deletions(-) diff --git a/src/tui/components/ConditionEditor.tsx b/src/tui/components/ConditionEditor.tsx index 6957db39..b9071de8 100644 --- a/src/tui/components/ConditionEditor.tsx +++ b/src/tui/components/ConditionEditor.tsx @@ -6,13 +6,9 @@ import { import React, { useState } from 'react'; import { - BOOLEAN_OPERATORS, DISPLAY_OPERATOR_CONFIG, DISPLAY_OPERATOR_LABELS, - NUMERIC_OPERATORS, OPERATOR_LABELS, - SET_OPERATORS, - STRING_OPERATORS, getConditionNot, getConditionOperator, getConditionValue, @@ -123,13 +119,13 @@ export const ConditionEditor: React.FC = ({ const getOperatorsInCategory = (category: 'Numeric' | 'String' | 'Boolean' | 'Set'): (Operator | DisplayOperator)[] => { switch (category) { case 'Numeric': - return [...NUMERIC_OPERATORS, 'notEquals']; + return ['equals', 'notEquals', 'greaterThan', 'greaterThanOrEqual', 'lessThan', 'lessThanOrEqual']; case 'String': - return [...STRING_OPERATORS, 'notContains', 'notStartsWith', 'notEndsWith']; + return ['equals', 'notEquals', 'contains', 'notContains', 'startsWith', 'notStartsWith', 'endsWith', 'notEndsWith', 'isEmpty', 'notEmpty']; case 'Boolean': - return [...BOOLEAN_OPERATORS, 'isFalse']; + return ['isTrue', 'isFalse']; case 'Set': - return SET_OPERATORS; + return ['in', 'notIn']; } }; @@ -360,8 +356,13 @@ export const ConditionEditor: React.FC = ({ }); // Check if current state matches a display operator + // Parse the value to its proper type so display operator detection works correctly + // (valueInput is always a string, but operators like isTrue need boolean values) + const parsedValue = isBooleanOperator(operator) + ? valueInput.toLowerCase() === 'true' + : valueInput; const currentCondition = { - [operator]: valueInput, + [operator]: parsedValue, ...(notFlag ? { not: true } : {}), ...(selectedWidget !== 'self' ? { widget: selectedWidget } : {}) }; diff --git a/src/tui/components/RulesEditor.tsx b/src/tui/components/RulesEditor.tsx index d64b7d33..f7e9ee6e 100644 --- a/src/tui/components/RulesEditor.tsx +++ b/src/tui/components/RulesEditor.tsx @@ -357,7 +357,10 @@ export const RulesEditor: React.FC = ({ widget, settings, onUp // Format based on display operator type if (displayOp === 'notEquals') { - return `when ${widgetName} ${displayLabel}${value}`; + if (typeof value === 'string') { + return `when ${widgetName} ${displayLabel} "${value}"`; + } + return `when ${widgetName} ${displayLabel} ${value}`; } if (displayOp === 'notContains' || displayOp === 'notStartsWith' || displayOp === 'notEndsWith') { return `when ${widgetName} ${displayLabel} "${value}"`; diff --git a/src/types/Condition.ts b/src/types/Condition.ts index 19c85b3e..00a42d1f 100644 --- a/src/types/Condition.ts +++ b/src/types/Condition.ts @@ -9,7 +9,9 @@ export type NumericOperator export type StringOperator = | 'contains' | 'startsWith' - | 'endsWith'; + | 'endsWith' + | 'equals' + | 'isEmpty'; export type BooleanOperator = | 'isTrue'; @@ -31,7 +33,9 @@ export const NUMERIC_OPERATORS: NumericOperator[] = [ export const STRING_OPERATORS: StringOperator[] = [ 'contains', 'startsWith', - 'endsWith' + 'endsWith', + 'equals', + 'isEmpty' ]; export const BOOLEAN_OPERATORS: BooleanOperator[] = [ @@ -53,15 +57,16 @@ export const ALL_OPERATORS: Operator[] = [ // Display labels for operators export const OPERATOR_LABELS: Record = { // Numeric - greaterThan: '>', - greaterThanOrEqual: '≥', - lessThan: '<', - lessThanOrEqual: '≤', - equals: '=', + equals: 'equals', + greaterThan: 'greater than', + greaterThanOrEqual: 'greater than or equal', + lessThan: 'less than', + lessThanOrEqual: 'less than or equal', // String contains: 'contains', startsWith: 'starts with', endsWith: 'ends with', + isEmpty: 'is empty', // Boolean isTrue: 'is true', // Set @@ -75,13 +80,15 @@ export type DisplayOperator | 'notContains' // contains + not | 'notStartsWith' // startsWith + not | 'notEndsWith' // endsWith + not + | 'notEmpty' // isEmpty + not | 'isFalse'; // isTrue: false (special case, not using not flag) export const DISPLAY_OPERATOR_LABELS: Record = { - notEquals: '≠', + notEquals: 'not equals', notContains: 'does not contain', notStartsWith: 'does not start with', notEndsWith: 'does not end with', + notEmpty: 'is not empty', isFalse: 'is false' }; @@ -91,6 +98,7 @@ export const DISPLAY_OPERATOR_CONFIG: Record): DisplayOperat return 'notStartsWith'; if (operator === 'endsWith' && notFlag) return 'notEndsWith'; + if (operator === 'isEmpty' && notFlag) + return 'notEmpty'; if (operator === 'isTrue' && value === false) return 'isFalse'; diff --git a/src/types/__tests__/Condition.test.ts b/src/types/__tests__/Condition.test.ts index a0d53491..2be0ec82 100644 --- a/src/types/__tests__/Condition.test.ts +++ b/src/types/__tests__/Condition.test.ts @@ -25,6 +25,8 @@ describe('Condition utilities', () => { expect(getConditionOperator({ contains: 'text' })).toBe('contains'); expect(getConditionOperator({ startsWith: 'prefix' })).toBe('startsWith'); expect(getConditionOperator({ endsWith: 'suffix' })).toBe('endsWith'); + expect(getConditionOperator({ equals: 'exact' })).toBe('equals'); + expect(getConditionOperator({ isEmpty: true })).toBe('isEmpty'); // Boolean operators expect(getConditionOperator({ isTrue: true })).toBe('isTrue'); @@ -61,11 +63,11 @@ describe('Condition utilities', () => { }); test('all operators have labels', () => { - expect(OPERATOR_LABELS.greaterThan).toBe('>'); - expect(OPERATOR_LABELS.greaterThanOrEqual).toBe('≥'); - expect(OPERATOR_LABELS.lessThan).toBe('<'); - expect(OPERATOR_LABELS.lessThanOrEqual).toBe('≤'); - expect(OPERATOR_LABELS.equals).toBe('='); + expect(OPERATOR_LABELS.greaterThan).toBe('greater than'); + expect(OPERATOR_LABELS.greaterThanOrEqual).toBe('greater than or equal'); + expect(OPERATOR_LABELS.lessThan).toBe('less than'); + expect(OPERATOR_LABELS.lessThanOrEqual).toBe('less than or equal'); + expect(OPERATOR_LABELS.equals).toBe('equals'); }); test('handles invalid conditions gracefully', () => { @@ -92,16 +94,21 @@ describe('Condition utilities', () => { // notEndsWith: endsWith + not expect(getDisplayOperator({ endsWith: 'suffix', not: true })).toBe('notEndsWith'); + // notEmpty: isEmpty + not + expect(getDisplayOperator({ isEmpty: true, not: true })).toBe('notEmpty'); + expect(getDisplayOperator({ isEmpty: true })).toBeNull(); + // isFalse: isTrue with value false expect(getDisplayOperator({ isTrue: false })).toBe('isFalse'); expect(getDisplayOperator({ isTrue: true })).toBeNull(); }); test('display operators have labels', () => { - expect(DISPLAY_OPERATOR_LABELS.notEquals).toBe('≠'); + expect(DISPLAY_OPERATOR_LABELS.notEquals).toBe('not equals'); expect(DISPLAY_OPERATOR_LABELS.notContains).toBe('does not contain'); expect(DISPLAY_OPERATOR_LABELS.notStartsWith).toBe('does not start with'); expect(DISPLAY_OPERATOR_LABELS.notEndsWith).toBe('does not end with'); + expect(DISPLAY_OPERATOR_LABELS.notEmpty).toBe('is not empty'); expect(DISPLAY_OPERATOR_LABELS.isFalse).toBe('is false'); }); }); \ No newline at end of file diff --git a/src/utils/__tests__/git.test.ts b/src/utils/__tests__/git.test.ts index 111d81bc..62e57dc1 100644 --- a/src/utils/__tests__/git.test.ts +++ b/src/utils/__tests__/git.test.ts @@ -23,7 +23,6 @@ const mockExecSync = execSync as unknown as { mock: { calls: unknown[][] }; mockImplementation: (impl: () => never) => void; mockReturnValueOnce: (value: string) => void; - mockReturnValueOnce: (value: string) => void; }; describe('git utils', () => { diff --git a/src/utils/__tests__/rules-engine.test.ts b/src/utils/__tests__/rules-engine.test.ts index 74f70525..94827a2a 100644 --- a/src/utils/__tests__/rules-engine.test.ts +++ b/src/utils/__tests__/rules-engine.test.ts @@ -519,6 +519,176 @@ describe('Rules Engine', () => { expect(result.bold).toBeUndefined(); }); + test('equals operator matches exact string', () => { + // Mock git commands for branch name + mockExecSync.mockReturnValueOnce('true\n'); + mockExecSync.mockReturnValueOnce('main\n'); + + const item = { + id: 'test', + type: 'git-branch', + color: 'white', + rules: [ + { + when: { equals: 'main' }, + apply: { color: 'cyan' } + } + ] + }; + + const result = applyRules(item, {}, [item]); + expect(result.color).toBe('cyan'); + }); + + test('equals operator does not match different string', () => { + // Mock git commands for branch name + mockExecSync.mockReturnValueOnce('true\n'); + mockExecSync.mockReturnValueOnce('develop\n'); + + const item = { + id: 'test', + type: 'git-branch', + color: 'white', + rules: [ + { + when: { equals: 'main' }, + apply: { color: 'cyan' } + } + ] + }; + + const result = applyRules(item, {}, [item]); + expect(result.color).toBe('white'); + }); + + test('equals with not flag acts as notEquals for strings', () => { + // Mock git commands for branch name + mockExecSync.mockReturnValueOnce('true\n'); + mockExecSync.mockReturnValueOnce('develop\n'); + + const item = { + id: 'test', + type: 'git-branch', + color: 'white', + rules: [ + { + when: { equals: 'main', not: true }, + apply: { color: 'yellow' } + } + ] + }; + + const result = applyRules(item, {}, [item]); + expect(result.color).toBe('yellow'); + }); + + test('equals still works for numeric values', () => { + const item = { + id: 'test', + type: 'context-percentage', + color: 'white', + rules: [ + { + when: { equals: 80 }, + apply: { color: 'green' } + } + ] + }; + + const result = applyRules(item, mockContext, [item]); + expect(result.color).toBe('green'); + }); + + test('isEmpty matches empty string widget', () => { + const item = { + id: 'test', + type: 'custom-text', + customText: '', + color: 'white', + rules: [ + { + when: { isEmpty: true }, + apply: { color: 'gray' } + } + ] + }; + + const result = applyRules(item, {}, [item]); + expect(result.color).toBe('gray'); + }); + + test('isEmpty does not match non-empty string', () => { + const item = { + id: 'test', + type: 'custom-text', + customText: 'hello', + color: 'white', + rules: [ + { + when: { isEmpty: true }, + apply: { color: 'gray' } + } + ] + }; + + const result = applyRules(item, {}, [item]); + expect(result.color).toBe('white'); + }); + + test('isEmpty matches null widget value', () => { + // custom-text with no customText set renders '' which getWidgetValue returns as null + const item = { + id: 'test', + type: 'custom-text', + color: 'white', + rules: [ + { + when: { isEmpty: true }, + apply: { color: 'gray' } + } + ] + }; + + const result = applyRules(item, {}, [item]); + expect(result.color).toBe('gray'); + }); + + test('isEmpty with not flag acts as notEmpty', () => { + const item = { + id: 'test', + type: 'custom-text', + customText: 'hello', + color: 'white', + rules: [ + { + when: { isEmpty: true, not: true }, + apply: { color: 'green' } + } + ] + }; + + const result = applyRules(item, {}, [item]); + expect(result.color).toBe('green'); + }); + + test('notEmpty does not match empty string', () => { + const item = { + id: 'test', + type: 'custom-text', + customText: '', + color: 'white', + rules: [ + { + when: { isEmpty: true, not: true }, + apply: { color: 'green' } + } + ] + }; + + const result = applyRules(item, {}, [item]); + expect(result.color).toBe('white'); + }); + test('string operators fail on numeric widgets', () => { const item = { id: 'test', diff --git a/src/utils/rules-engine.ts b/src/utils/rules-engine.ts index 2d7ac2a5..bfce2bf3 100644 --- a/src/utils/rules-engine.ts +++ b/src/utils/rules-engine.ts @@ -56,6 +56,10 @@ function evaluateStringCondition( return widgetValue.startsWith(conditionValue); case 'endsWith': return widgetValue.endsWith(conditionValue); + case 'equals': + return widgetValue === conditionValue; + case 'isEmpty': + return widgetValue === ''; default: return false; } @@ -157,6 +161,12 @@ function evaluateCondition( // Get the target widget's value (generic - can be number, string, or boolean) const widgetValue = getWidgetValue(targetWidget.type, context, targetWidget); + // isEmpty treats null as empty + if (operator === 'isEmpty') { + const result = widgetValue === null || (typeof widgetValue === 'string' && widgetValue === ''); + return notFlag ? !result : result; + } + if (widgetValue === null) { return false; // Widget has no evaluable value } @@ -164,7 +174,16 @@ function evaluateCondition( // Route to appropriate evaluation function based on operator type let result: boolean; - if (isNumericOperator(operator)) { + // equals is shared between numeric and string - route by value types + if (operator === 'equals') { + if (typeof widgetValue === 'string' && typeof conditionValue === 'string') { + result = widgetValue === conditionValue; + } else if (typeof widgetValue === 'number' && typeof conditionValue === 'number') { + result = widgetValue === conditionValue; + } else { + return false; // Type mismatch + } + } else if (isNumericOperator(operator)) { if (typeof widgetValue !== 'number' || typeof conditionValue !== 'number') { return false; // Type mismatch } From 188414c88ad035f7e675d8a01d85307744733a0e Mon Sep 17 00:00:00 2001 From: Mark O'Keefe Date: Mon, 23 Mar 2026 18:57:13 +1100 Subject: [PATCH 06/42] feat: add editorMode state and Tab toggle to ItemsEditor Wires up the mode toggle infrastructure so Tab key switches between 'items' and 'color' modes in ItemsEditor, following the pattern established in RulesEditor. Uses widget's supportsColors() method to determine if Tab toggle is applicable for the selected widget. Co-Authored-By: Claude Sonnet 4.6 --- src/tui/components/ItemsEditor.tsx | 38 ++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/src/tui/components/ItemsEditor.tsx b/src/tui/components/ItemsEditor.tsx index 2571495f..74929c1a 100644 --- a/src/tui/components/ItemsEditor.tsx +++ b/src/tui/components/ItemsEditor.tsx @@ -24,6 +24,7 @@ import { import { ConfirmDialog } from './ConfirmDialog'; import { RulesEditor } from './RulesEditor'; +import { type ColorEditorState } from './color-editor/input-handlers'; import { handleMoveInputMode, handleNormalInputMode, @@ -49,6 +50,14 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB const [widgetPicker, setWidgetPicker] = useState(null); const [showClearConfirm, setShowClearConfirm] = useState(false); const [rulesEditorWidget, setRulesEditorWidget] = useState(null); + const [editorMode, setEditorMode] = useState<'items' | 'color'>('items'); + const [colorEditorState, setColorEditorState] = useState({ + editingBackground: false, + hexInputMode: false, + hexInput: '', + ansi256InputMode: false, + ansi256Input: '' + }); const separatorChars = ['|', '-', ',', ' ']; const widgetCatalog = getWidgetCatalog(settings); @@ -169,6 +178,35 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB return; } + // Tab toggles between items and color mode — check before mode-specific routing + // Only when: widgets exist, no overlay active (picker, move mode, custom editor, rules editor, clear confirm) + if (key.tab && widgets.length > 0 && !widgetPicker && !moveMode) { + const widget = widgets[selectedIndex]; + if (widget) { + const widgetImpl = widget.type !== 'separator' && widget.type !== 'flex-separator' + ? getWidget(widget.type) + : null; + if (widgetImpl?.supportsColors(widget)) { + if (editorMode === 'color') { + // Reset hex/ansi256 input modes when switching back to items mode + if (colorEditorState.hexInputMode || colorEditorState.ansi256InputMode) { + setColorEditorState(prev => ({ + ...prev, + hexInputMode: false, + hexInput: '', + ansi256InputMode: false, + ansi256Input: '' + })); + } + setEditorMode('items'); + } else { + setEditorMode('color'); + } + } + } + return; + } + if (widgetPicker) { handlePickerInputMode({ input, From fc964e203ad530a3da1b256a33669e916284aa1c Mon Sep 17 00:00:00 2001 From: Mark O'Keefe Date: Mon, 23 Mar 2026 19:02:12 +1100 Subject: [PATCH 07/42] fix: auto-reset color mode when navigating to a widget that doesn't support colors Prevents input misrouting by detecting at the top of the useInput handler that editorMode is 'color' but the currently selected widget doesn't support colors, and resetting back to 'items' mode. Also updates the Tab block comment to clarify it always consumes the Tab key. Co-Authored-By: Claude Sonnet 4.6 --- src/tui/components/ItemsEditor.tsx | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/src/tui/components/ItemsEditor.tsx b/src/tui/components/ItemsEditor.tsx index 74929c1a..a04b03fb 100644 --- a/src/tui/components/ItemsEditor.tsx +++ b/src/tui/components/ItemsEditor.tsx @@ -178,7 +178,7 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB return; } - // Tab toggles between items and color mode — check before mode-specific routing + // Tab toggles between items and color mode — always consumed here, never falls through // Only when: widgets exist, no overlay active (picker, move mode, custom editor, rules editor, clear confirm) if (key.tab && widgets.length > 0 && !widgetPicker && !moveMode) { const widget = widgets[selectedIndex]; @@ -207,6 +207,27 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB return; } + // Auto-reset color mode if the current widget no longer supports colors + // (e.g. user navigated to a different widget while in color mode) + if (editorMode === 'color') { + const widget = widgets[selectedIndex]; + const widgetImpl = widget && widget.type !== 'separator' && widget.type !== 'flex-separator' + ? getWidget(widget.type) + : null; + const supportsColors = widget !== undefined && (widgetImpl?.supportsColors(widget) ?? false); + if (!supportsColors) { + setEditorMode('items'); + setColorEditorState(prev => ({ + ...prev, + editingBackground: false, + hexInputMode: false, + hexInput: '', + ansi256InputMode: false, + ansi256Input: '' + })); + } + } + if (widgetPicker) { handlePickerInputMode({ input, From 78830424232fc6cbc9a7c70c60c349339d8be59f Mon Sep 17 00:00:00 2001 From: Mark O'Keefe Date: Mon, 23 Mar 2026 19:04:33 +1100 Subject: [PATCH 08/42] feat: route keyboard input to handleColorInput in color mode When editorMode is 'color', intercept input before widget picker/move mode handlers and delegate to the shared handleColorInput. Up/down arrows still navigate widgets, ESC cancels hex/ansi256 sub-modes or returns to items mode, and all other keys (left/right cycle colors, f/b/h/a/r) go through the shared color handler. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/tui/components/ItemsEditor.tsx | 60 +++++++++++++++++++++++++++++- 1 file changed, 59 insertions(+), 1 deletion(-) diff --git a/src/tui/components/ItemsEditor.tsx b/src/tui/components/ItemsEditor.tsx index a04b03fb..34c54941 100644 --- a/src/tui/components/ItemsEditor.tsx +++ b/src/tui/components/ItemsEditor.tsx @@ -24,7 +24,10 @@ import { import { ConfirmDialog } from './ConfirmDialog'; import { RulesEditor } from './RulesEditor'; -import { type ColorEditorState } from './color-editor/input-handlers'; +import { + handleColorInput, + type ColorEditorState +} from './color-editor/input-handlers'; import { handleMoveInputMode, handleNormalInputMode, @@ -228,6 +231,61 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB } } + // Color mode input routing + if (editorMode === 'color') { + const widget = widgets[selectedIndex]; + if (widget) { + // Up/Down for navigation (same as items mode) + if (key.upArrow) { + setSelectedIndex(Math.max(0, selectedIndex - 1)); + return; + } + if (key.downArrow) { + setSelectedIndex(Math.min(widgets.length - 1, selectedIndex + 1)); + return; + } + + // ESC: if hex/ansi256 sub-mode is active, let handleColorInput cancel it + // Otherwise, switch back to items mode + if (key.escape) { + if (colorEditorState.hexInputMode || colorEditorState.ansi256InputMode) { + handleColorInput({ + input, + key, + widget, + settings, + state: colorEditorState, + setState: setColorEditorState, + onUpdate: (updatedWidget) => { + const newWidgets = [...widgets]; + newWidgets[selectedIndex] = updatedWidget; + onUpdate(newWidgets); + } + }); + } else { + setEditorMode('items'); + } + return; + } + + // Delegate all other input to handleColorInput + handleColorInput({ + input, + key, + widget, + settings, + state: colorEditorState, + setState: setColorEditorState, + onUpdate: (updatedWidget) => { + const newWidgets = [...widgets]; + newWidgets[selectedIndex] = updatedWidget; + onUpdate(newWidgets); + } + }); + } + return; + } + if (widgetPicker) { handlePickerInputMode({ input, From 4e27c418e0da6345a985e1887aceed647675b163 Mon Sep 17 00:00:00 2001 From: Mark O'Keefe Date: Mon, 23 Mar 2026 19:08:41 +1100 Subject: [PATCH 09/42] refactor: deduplicate onUpdate closure and simplify ESC handling in color mode Extract updateWidget callback before the ESC check and collapse the ESC branch so that sub-mode ESC falls through to handleColorInput rather than duplicating the inline closure. Add defensive comment on the unconditional return at the end of the color block. Co-Authored-By: Claude Sonnet 4.6 --- src/tui/components/ItemsEditor.tsx | 40 +++++++++++------------------- 1 file changed, 14 insertions(+), 26 deletions(-) diff --git a/src/tui/components/ItemsEditor.tsx b/src/tui/components/ItemsEditor.tsx index 34c54941..a79bc299 100644 --- a/src/tui/components/ItemsEditor.tsx +++ b/src/tui/components/ItemsEditor.tsx @@ -245,30 +245,20 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB return; } - // ESC: if hex/ansi256 sub-mode is active, let handleColorInput cancel it - // Otherwise, switch back to items mode - if (key.escape) { - if (colorEditorState.hexInputMode || colorEditorState.ansi256InputMode) { - handleColorInput({ - input, - key, - widget, - settings, - state: colorEditorState, - setState: setColorEditorState, - onUpdate: (updatedWidget) => { - const newWidgets = [...widgets]; - newWidgets[selectedIndex] = updatedWidget; - onUpdate(newWidgets); - } - }); - } else { - setEditorMode('items'); - } + // ESC with no sub-mode active: switch back to items mode. + // If a sub-mode is active, fall through to handleColorInput which handles ESC internally. + if (key.escape && !colorEditorState.hexInputMode && !colorEditorState.ansi256InputMode) { + setEditorMode('items'); return; } - // Delegate all other input to handleColorInput + const updateWidget = (updatedWidget: WidgetItem) => { + const newWidgets = [...widgets]; + newWidgets[selectedIndex] = updatedWidget; + onUpdate(newWidgets); + }; + + // Delegate all input (including ESC in sub-modes) to handleColorInput handleColorInput({ input, key, @@ -276,13 +266,11 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB settings, state: colorEditorState, setState: setColorEditorState, - onUpdate: (updatedWidget) => { - const newWidgets = [...widgets]; - newWidgets[selectedIndex] = updatedWidget; - onUpdate(newWidgets); - } + onUpdate: updateWidget }); } + // Return unconditionally to prevent fall-through even when widget is undefined + // (shouldn't happen since Tab guards widgets.length > 0, but is defensive) return; } From 4374bb5726efed997be8bcb1473431ff1b382b8a Mon Sep 17 00:00:00 2001 From: Mark O'Keefe Date: Mon, 23 Mar 2026 19:11:52 +1100 Subject: [PATCH 10/42] feat: add color mode rendering to ItemsEditor Add visual feedback when in color mode: magenta mode indicator in title bar, current color info display with styled preview, hex/ANSI256 input prompts, styled widget labels using applyColors, and magenta selector arrow. Separators shown as dimmed in color mode. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/tui/components/ItemsEditor.tsx | 127 +++++++++++++++++++++++++++-- 1 file changed, 121 insertions(+), 6 deletions(-) diff --git a/src/tui/components/ItemsEditor.tsx b/src/tui/components/ItemsEditor.tsx index a79bc299..b7bb2ee9 100644 --- a/src/tui/components/ItemsEditor.tsx +++ b/src/tui/components/ItemsEditor.tsx @@ -5,6 +5,7 @@ import { } from 'ink'; import React, { useState } from 'react'; +import { getColorLevelString } from '../../types/ColorLevel'; import type { Settings } from '../../types/Settings'; import type { CustomKeybind, @@ -12,7 +13,10 @@ import type { WidgetItem, WidgetItemType } from '../../types/Widget'; -import { getBackgroundColorsForPowerline } from '../../utils/colors'; +import { + applyColors, + getBackgroundColorsForPowerline +} from '../../utils/colors'; import { generateGuid } from '../../utils/guid'; import { canDetectTerminalWidth } from '../../utils/terminal'; import { @@ -25,6 +29,7 @@ import { import { ConfirmDialog } from './ConfirmDialog'; import { RulesEditor } from './RulesEditor'; import { + getCurrentColorInfo, handleColorInput, type ColorEditorState } from './color-editor/input-handlers'; @@ -483,6 +488,13 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB {' '} {moveMode && [MOVE MODE]} + {!moveMode && !widgetPicker && editorMode === 'color' && ( + + [COLOR MODE + {colorEditorState.editingBackground ? ' - BACKGROUND' : ' - FOREGROUND'} + ] + + )} {widgetPicker && {`[${pickerActionLabel.toUpperCase()}]`}} {(settings.powerline.enabled || Boolean(settings.defaultSeparator)) && ( @@ -631,6 +643,76 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB )} )} + {!widgetPicker && editorMode === 'color' && colorEditorState.hexInputMode && ( + + Enter 6-digit hex color code (without #): + + # + {colorEditorState.hexInput} + + {colorEditorState.hexInput.length < 6 ? '_'.repeat(6 - colorEditorState.hexInput.length) : ''} + + + + )} + {!widgetPicker && editorMode === 'color' && colorEditorState.ansi256InputMode && ( + + Enter ANSI 256 color code (0-255): + + {colorEditorState.ansi256Input} + + {colorEditorState.ansi256Input.length === 0 + ? '___' + : colorEditorState.ansi256Input.length === 1 + ? '__' + : colorEditorState.ansi256Input.length === 2 + ? '_' + : ''} + + + + )} + {!widgetPicker && editorMode === 'color' && !colorEditorState.hexInputMode && !colorEditorState.ansi256InputMode && (() => { + const selectedWidget = widgets[selectedIndex]; + if (!selectedWidget) { + return null; + } + + const isSep = selectedWidget.type === 'separator' || selectedWidget.type === 'flex-separator'; + if (isSep) { + return null; + } + + const { colorIndex, totalColors, displayName } = getCurrentColorInfo( + selectedWidget, + colorEditorState.editingBackground + ); + + const colorType = colorEditorState.editingBackground ? 'background' : 'foreground'; + const colorNumber = colorIndex === -1 ? 'custom' : `${colorIndex}/${totalColors}`; + + const level = getColorLevelString(settings.colorLevel); + const styledColor = colorEditorState.editingBackground + ? applyColors(` ${displayName} `, undefined, selectedWidget.backgroundColor, false, level) + : applyColors(displayName, selectedWidget.color, undefined, false, level); + + return ( + + + Current + {' '} + {colorType} + {' '} + ( + {colorNumber} + ): + {' '} + {styledColor} + {selectedWidget.bold && [BOLD]} + + + ); + })()} {!widgetPicker && ( {widgets.length === 0 ? ( @@ -639,20 +721,53 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB <> {widgets.map((widget, index) => { const isSelected = index === selectedIndex; - const widgetImpl = widget.type !== 'separator' && widget.type !== 'flex-separator' ? getWidget(widget.type) : null; + const isSep = widget.type === 'separator' || widget.type === 'flex-separator'; + const widgetImpl = !isSep ? getWidget(widget.type) : null; const { displayText, modifierText } = widgetImpl?.getEditorDisplay(widget) ?? { displayText: getWidgetDisplay(widget) }; const supportsRawValue = widgetImpl?.supportsRawValue() ?? false; + const inColorMode = editorMode === 'color'; + + // Determine selector color: blue for move, magenta for color, green for items + const selectorColor = moveMode ? 'blue' : inColorMode ? 'magenta' : 'green'; + + // Build styled label for color mode + let styledLabel: string | undefined; + if (inColorMode && !isSep && widgetImpl) { + const colorLevel = getColorLevelString(settings.colorLevel); + const defaultColor = widgetImpl.getDefaultColor(); + const fgColor = widget.color ?? defaultColor; + const bgColor = widget.backgroundColor; + const boldFlag = widget.bold ?? false; + styledLabel = applyColors( + displayText || getWidgetDisplay(widget), + fgColor, + bgColor, + boldFlag, + colorLevel + ); + } return ( - + {isSelected ? (moveMode ? '◆ ' : '▶ ') : ' '} - - {`${index + 1}. ${displayText || getWidgetDisplay(widget)}`} - + {inColorMode && isSep ? ( + + {`${index + 1}. ${displayText || getWidgetDisplay(widget)}`} + + ) : inColorMode && styledLabel ? ( + + {`${index + 1}. `} + {styledLabel} + + ) : ( + + {`${index + 1}. ${displayText || getWidgetDisplay(widget)}`} + + )} {modifierText && ( {' '} From f9b4f1c2679a453faac208d7a11bd70c8a739a55 Mon Sep 17 00:00:00 2001 From: Mark O'Keefe Date: Mon, 23 Mar 2026 19:17:31 +1100 Subject: [PATCH 11/42] feat: make ItemsEditor help text mode-aware for color mode Build help text dynamically via buildHelpText() that switches between items mode and color mode keybinds. Color mode shows cycle/bold/reset hints plus hex/ansi256 conditionally on colorLevel. ESC now shows its destination in both modes. Rename "(x) exceptions" to "(x) rules". Co-Authored-By: Claude Sonnet 4.6 --- src/tui/components/ItemsEditor.tsx | 69 +++++++++++++++++++++--------- 1 file changed, 48 insertions(+), 21 deletions(-) diff --git a/src/tui/components/ItemsEditor.tsx b/src/tui/components/ItemsEditor.tsx index b7bb2ee9..6e0011d3 100644 --- a/src/tui/components/ItemsEditor.tsx +++ b/src/tui/components/ItemsEditor.tsx @@ -389,26 +389,53 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB const canMerge = currentWidget && selectedIndex < widgets.length - 1 && !isSeparator && !isFlexSeparator; const hasWidgets = widgets.length > 0; - // Build main help text (without custom keybinds) - let helpText = hasWidgets - ? '↑↓ select, ←→ open type picker' - : '(a)dd via picker, (i)nsert via picker'; - if (isSeparator) { - helpText += ', Space edit separator'; - } - if (hasWidgets) { - helpText += ', Enter to move, (a)dd via picker, (i)nsert via picker, (d)elete, (c)lear line'; - } - if (canToggleRaw) { - helpText += ', (r)aw value'; - } - if (canMerge) { - helpText += ', (m)erge'; - } - if (!isSeparator && !isFlexSeparator && hasWidgets) { - helpText += ', (x) exceptions'; - } - helpText += ', ESC back'; + // Build mode-aware help text + const buildHelpText = (): string => { + if (editorMode === 'color') { + const { editingBackground, hexInputMode, ansi256InputMode } = colorEditorState; + + if (hexInputMode || ansi256InputMode) { + // Sub-modes render their own help text inline + return ''; + } + + const colorType = editingBackground ? 'background' : 'foreground'; + const hexAnsiHelp = settings.colorLevel === 3 + ? ', (h)ex' + : settings.colorLevel === 2 + ? ', (a)nsi256' + : ''; + + return `←→ cycle ${colorType}, (f) bg/fg, (b)old${hexAnsiHelp}, (r)eset\nTab: items mode, ESC: items mode`; + } + + // Items mode + let text = hasWidgets + ? '↑↓ select, ←→ open type picker' + : '(a)dd via picker, (i)nsert via picker'; + if (isSeparator) { + text += ', Space edit separator'; + } + if (hasWidgets) { + text += ', Enter to move, (a)dd via picker, (i)nsert via picker, (d)elete, (c)lear line'; + } + if (canToggleRaw) { + text += ', (r)aw value'; + } + if (canMerge) { + text += ', (m)erge'; + } + if (!isSeparator && !isFlexSeparator && hasWidgets) { + text += ', (x) rules'; + } + if (hasWidgets && !isSeparator && !isFlexSeparator) { + text += ', Tab: color mode'; + } + text += ', ESC: back'; + return text; + }; + + const helpText = buildHelpText(); // Build custom keybinds text const customKeybindsText = customKeybinds.map(kb => kb.label).join(', '); @@ -546,7 +573,7 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB ) : ( {helpText} - {customKeybindsText || ' '} + {editorMode === 'items' && {customKeybindsText || ' '}} )} {hasFlexSeparator && !widthDetectionAvailable && ( From de3a851c8d1475e0fda33d128717c26d28fe51d2 Mon Sep 17 00:00:00 2001 From: Mark O'Keefe Date: Mon, 23 Mar 2026 19:43:32 +1100 Subject: [PATCH 12/42] feat: remove Edit Colors from main menu (Task 2.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the separate 'Edit Colors' entry point from MainMenu since color editing is now unified into the ItemsEditor via Tab toggle. Update menuSelections index references in App.tsx for terminalConfig (3→2) and globalOverrides (4→3) to account for the removed item. Co-Authored-By: Claude Sonnet 4.6 --- src/tui/App.tsx | 11 ++++------- src/tui/components/MainMenu.tsx | 7 ------- 2 files changed, 4 insertions(+), 14 deletions(-) diff --git a/src/tui/App.tsx b/src/tui/App.tsx index aead5585..9d13de39 100644 --- a/src/tui/App.tsx +++ b/src/tui/App.tsx @@ -252,9 +252,6 @@ export const App: React.FC = () => { case 'lines': setScreen('lines'); break; - case 'colors': - setScreen('colorLines'); - break; case 'terminalConfig': setScreen('terminalConfig'); break; @@ -444,8 +441,8 @@ export const App: React.FC = () => { if (target === 'width') { setScreen('terminalWidth'); } else { - // Save that we came from 'terminalConfig' menu (index 3) - setMenuSelections(prev => ({ ...prev, main: 3 })); + // Save that we came from 'terminalConfig' menu (index 2) + setMenuSelections(prev => ({ ...prev, main: 2 })); setScreen('main'); } }} @@ -469,8 +466,8 @@ export const App: React.FC = () => { setSettings(updatedSettings); }} onBack={() => { - // Save that we came from 'globalOverrides' menu (index 4) - setMenuSelections(prev => ({ ...prev, main: 4 })); + // Save that we came from 'globalOverrides' menu (index 3) + setMenuSelections(prev => ({ ...prev, main: 3 })); setScreen('main'); }} /> diff --git a/src/tui/components/MainMenu.tsx b/src/tui/components/MainMenu.tsx index f0148509..51dd254e 100644 --- a/src/tui/components/MainMenu.tsx +++ b/src/tui/components/MainMenu.tsx @@ -10,7 +10,6 @@ import { type PowerlineFontStatus } from '../../utils/powerline'; import { List } from './List'; export type MainMenuOption = 'lines' - | 'colors' | 'powerline' | 'terminalConfig' | 'globalOverrides' @@ -50,12 +49,6 @@ export const MainMenu: React.FC = ({ description: 'Configure any number of status lines with various widgets like model info, git status, and token usage' }, - { - label: '🎨 Edit Colors', - value: 'colors', - description: - 'Customize colors for each widget including foreground, background, and bold styling' - }, { label: '⚡ Powerline Setup', value: 'powerline', From 573e535a6e4b3577f5fd36d2cb9c3fba8d0daf88 Mon Sep 17 00:00:00 2001 From: Mark O'Keefe Date: Mon, 23 Mar 2026 19:52:08 +1100 Subject: [PATCH 13/42] refactor: remove dead colorLines and colors screen states from App.tsx The colorLines and colors AppScreen values became unreachable after Task 2.1 removed the 'Edit Colors' menu entry. This commit removes those screen states from the AppScreen type union, their JSX render blocks, and the ColorMenu import from the barrel import. Co-Authored-By: Claude Sonnet 4.6 --- src/tui/App.tsx | 41 ----------------------------------------- 1 file changed, 41 deletions(-) diff --git a/src/tui/App.tsx b/src/tui/App.tsx index 9d13de39..7b4fd8e8 100644 --- a/src/tui/App.tsx +++ b/src/tui/App.tsx @@ -42,7 +42,6 @@ import { import { getPackageVersion } from '../utils/terminal'; import { - ColorMenu, ConfirmDialog, GlobalOverridesMenu, InstallMenu, @@ -66,8 +65,6 @@ interface FlashMessage { type AppScreen = 'main' | 'lines' | 'items' - | 'colorLines' - | 'colors' | 'terminalWidth' | 'terminalConfig' | 'globalOverrides' @@ -393,44 +390,6 @@ export const App: React.FC = () => { settings={settings} /> )} - {screen === 'colorLines' && ( - { - setMenuSelections(prev => ({ ...prev, lines: line })); - setSelectedLine(line); - setScreen('colors'); - }} - onBack={() => { - // Save that we came from 'colors' menu (index 1) - setMenuSelections(prev => ({ ...prev, main: 1 })); - setScreen('main'); - }} - initialSelection={menuSelections.lines} - title='Select Line to Edit Colors' - blockIfPowerlineActive={true} - settings={settings} - allowEditing={false} - /> - )} - {screen === 'colors' && ( - { - // Update only the selected line - const newLines = [...settings.lines]; - newLines[selectedLine] = updatedWidgets; - setSettings({ ...settings, lines: newLines }); - }} - onBack={() => { - // Go back to line selection for colors - setScreen('colorLines'); - }} - /> - )} {screen === 'terminalConfig' && ( Date: Mon, 23 Mar 2026 19:54:15 +1100 Subject: [PATCH 14/42] feat: remove ColorMenu component and color-menu mutations ColorMenu and its array-based mutation helpers are now fully replaced by the shared handleColorInput handler in the unified editing flow. Removes ColorMenu.tsx, color-menu/mutations.ts, and its test file, and drops the ColorMenu export from the components barrel. Co-Authored-By: Claude Sonnet 4.6 --- src/tui/components/ColorMenu.tsx | 514 ------------------ .../color-menu/__tests__/mutations.test.ts | 136 ----- src/tui/components/color-menu/mutations.ts | 148 ----- src/tui/components/index.ts | 1 - 4 files changed, 799 deletions(-) delete mode 100644 src/tui/components/ColorMenu.tsx delete mode 100644 src/tui/components/color-menu/__tests__/mutations.test.ts delete mode 100644 src/tui/components/color-menu/mutations.ts diff --git a/src/tui/components/ColorMenu.tsx b/src/tui/components/ColorMenu.tsx deleted file mode 100644 index 8bc07ad5..00000000 --- a/src/tui/components/ColorMenu.tsx +++ /dev/null @@ -1,514 +0,0 @@ -import chalk from 'chalk'; -import { - Box, - Text, - useInput -} from 'ink'; -import SelectInput from 'ink-select-input'; -import React, { useState } from 'react'; - -import { getColorLevelString } from '../../types/ColorLevel'; -import type { Settings } from '../../types/Settings'; -import type { WidgetItem } from '../../types/Widget'; -import { - applyColors, - getAvailableBackgroundColorsForUI, - getAvailableColorsForUI -} from '../../utils/colors'; -import { shouldInsertInput } from '../../utils/input-guards'; -import { getWidget } from '../../utils/widgets'; - -import { ConfirmDialog } from './ConfirmDialog'; -import { - clearAllWidgetStyling, - cycleWidgetColor, - resetWidgetStyling, - setWidgetColor, - toggleWidgetBold -} from './color-menu/mutations'; - -export function getColorsWithHidden(): { name: string; value: string }[] { - return [ - { name: 'Hidden', value: 'hidden' }, - ...getAvailableColorsForUI() - ]; -} - -export interface ColorMenuProps { - widgets: WidgetItem[]; - lineIndex?: number; - settings: Settings; - onUpdate: (widgets: WidgetItem[]) => void; - onBack: () => void; -} - -export const ColorMenu: React.FC = ({ widgets, lineIndex, settings, onUpdate, onBack }) => { - const [showSeparators, setShowSeparators] = useState(false); - const [hexInputMode, setHexInputMode] = useState(false); - const [hexInput, setHexInput] = useState(''); - const [ansi256InputMode, setAnsi256InputMode] = useState(false); - const [ansi256Input, setAnsi256Input] = useState(''); - const [showClearConfirm, setShowClearConfirm] = useState(false); - - const powerlineEnabled = settings.powerline.enabled; - - const colorableWidgets = widgets.filter((widget) => { - // Include separators only if showSeparators is true - if (widget.type === 'separator') { - return showSeparators; - } - // Use the widget's supportsColors method - const widgetInstance = getWidget(widget.type); - // Include unknown widgets (they might support colors, we just don't know) - return widgetInstance ? widgetInstance.supportsColors(widget) : true; - }); - const [highlightedItemId, setHighlightedItemId] = useState(colorableWidgets[0]?.id ?? null); - const [editingBackground, setEditingBackground] = useState(false); - - // Handle keyboard input - const hasNoItems = colorableWidgets.length === 0; - useInput((input, key) => { - // If no items, any key goes back - if (hasNoItems) { - onBack(); - return; - } - - // Skip input handling when confirmation is active - let ConfirmDialog handle it - if (showClearConfirm) { - return; - } - - // Handle hex input mode - if (hexInputMode) { - // Disable arrow keys in input mode - if (key.upArrow || key.downArrow) { - return; - } - if (key.escape) { - setHexInputMode(false); - setHexInput(''); - } else if (key.return) { - // Validate and apply the hex color - if (hexInput.length === 6) { - const hexColor = `hex:${hexInput}`; - const selectedWidget = colorableWidgets.find(widget => widget.id === highlightedItemId); - if (selectedWidget) { - const newItems = setWidgetColor(widgets, selectedWidget.id, hexColor, editingBackground); - onUpdate(newItems); - } - setHexInputMode(false); - setHexInput(''); - } - } else if (key.backspace || key.delete) { - setHexInput(hexInput.slice(0, -1)); - } else if (shouldInsertInput(input, key) && hexInput.length < 6) { - // Only accept hex characters (0-9, A-F, a-f) - const upperInput = input.toUpperCase(); - if (/^[0-9A-F]$/.test(upperInput)) { - setHexInput(hexInput + upperInput); - } - } - return; - } - - // Handle ansi256 input mode - if (ansi256InputMode) { - // Disable arrow keys in input mode - if (key.upArrow || key.downArrow) { - return; - } - if (key.escape) { - setAnsi256InputMode(false); - setAnsi256Input(''); - } else if (key.return) { - // Validate and apply the ansi256 color - const code = parseInt(ansi256Input, 10); - if (!isNaN(code) && code >= 0 && code <= 255) { - const ansiColor = `ansi256:${code}`; - - const selectedWidget = colorableWidgets.find(widget => widget.id === highlightedItemId); - - if (selectedWidget) { - const newItems = setWidgetColor(widgets, selectedWidget.id, ansiColor, editingBackground); - - onUpdate(newItems); - setAnsi256InputMode(false); - setAnsi256Input(''); - } - } - } else if (key.backspace || key.delete) { - setAnsi256Input(ansi256Input.slice(0, -1)); - } else if (shouldInsertInput(input, key) && ansi256Input.length < 3) { - // Only accept numeric characters (0-9) - if (/^[0-9]$/.test(input)) { - const newInput = ansi256Input + input; - const code = parseInt(newInput, 10); - // Only allow if it won't exceed 255 - if (code <= 255) { - setAnsi256Input(newInput); - } - } - } - return; - } - - // Ignore number keys to prevent SelectInput numerical navigation - if (input && /^[0-9]$/.test(input)) { - return; - } - - // Normal keyboard handling when there are items - if (key.escape) { - if (editingBackground) { - setEditingBackground(false); - } else { - onBack(); - } - } else if (input === 'h' || input === 'H') { - // Enter hex input mode (only in truecolor mode) - if (highlightedItemId && highlightedItemId !== 'back' && settings.colorLevel === 3) { - setHexInputMode(true); - setHexInput(''); - } - } else if (input === 'a' || input === 'A') { - // Enter ansi256 input mode (only in 256 color mode) - if (highlightedItemId && highlightedItemId !== 'back' && settings.colorLevel === 2) { - setAnsi256InputMode(true); - setAnsi256Input(''); - } - } else if ((input === 's' || input === 'S') && !key.ctrl) { - // Toggle show separators (only if not in powerline mode and no default separator) - if (!settings.powerline.enabled && !settings.defaultSeparator) { - setShowSeparators(!showSeparators); - // The highlighted item ID will be maintained, and we'll recalculate - // the initial index when rendering the SelectInput - } - } else if (input === 'f' || input === 'F') { - if (colorableWidgets.length > 0) { - setEditingBackground(!editingBackground); - } - } else if (input === 'b' || input === 'B') { - if (highlightedItemId && highlightedItemId !== 'back') { - // Toggle bold for the highlighted item - const selectedWidget = colorableWidgets.find(widget => widget.id === highlightedItemId); - if (selectedWidget) { - const newItems = toggleWidgetBold(widgets, selectedWidget.id); - onUpdate(newItems); - } - } - } else if (input === 'r' || input === 'R') { - if (highlightedItemId && highlightedItemId !== 'back') { - // Reset all styling (color, background, and bold) for the highlighted item - const selectedWidget = colorableWidgets.find(widget => widget.id === highlightedItemId); - if (selectedWidget) { - const newItems = resetWidgetStyling(widgets, selectedWidget.id); - onUpdate(newItems); - } - } - } else if (input === 'c' || input === 'C') { - // Show clear all confirmation - setShowClearConfirm(true); - } else if (key.leftArrow || key.rightArrow) { - // Cycle through colors with arrow keys - if (highlightedItemId && highlightedItemId !== 'back') { - const selectedWidget = colorableWidgets.find(widget => widget.id === highlightedItemId); - if (selectedWidget) { - const newItems = cycleWidgetColor({ - widgets, - widgetId: selectedWidget.id, - direction: key.rightArrow ? 'right' : 'left', - editingBackground, - colors, - backgroundColors: bgColors - }); - onUpdate(newItems); - } - } - } - }); - - if (hasNoItems) { - return ( - - - Configure Colors - {lineIndex !== undefined ? ` - Line ${lineIndex + 1}` : ''} - - No colorable widgets in the status line. - Add a widget first to continue. - Press any key to go back... - - ); - } - - const getItemLabel = (widget: WidgetItem) => { - if (widget.type === 'separator') { - const char = widget.character ?? '|'; - return `Separator: ${char === ' ' ? 'space' : char}`; - } - if (widget.type === 'flex-separator') { - return 'Flex Separator'; - } - - const widgetImpl = getWidget(widget.type); - return widgetImpl ? widgetImpl.getDisplayName() : `Unknown: ${widget.type}`; - }; - - // Color list for cycling - // Get available colors from colors.ts - const colorOptions = getAvailableColorsForUI(); - const colors = colorOptions.map(c => c.value || ''); - - // For background, get background colors - const bgColorOptions = getAvailableBackgroundColorsForUI(); - const bgColors = bgColorOptions.map(c => c.value || ''); - - // Create menu items with colored labels - const menuItems = colorableWidgets.map((widget, index) => { - const label = `${index + 1}: ${getItemLabel(widget)}`; - // Apply both foreground and background colors - const level = getColorLevelString(settings.colorLevel); - let defaultColor = 'white'; - if (widget.type !== 'separator' && widget.type !== 'flex-separator') { - const widgetImpl = getWidget(widget.type); - if (widgetImpl) { - defaultColor = widgetImpl.getDefaultColor(); - } - } - const styledLabel = applyColors(label, widget.color ?? defaultColor, widget.backgroundColor, widget.bold, level); - return { - label: styledLabel, - value: widget.id - }; - }); - menuItems.push({ label: '← Back', value: 'back' }); - - const handleSelect = (selected: { value: string }) => { - if (selected.value === 'back') { - onBack(); - } - // Enter no longer cycles colors - use left/right arrow keys instead - }; - - const handleHighlight = (item: { value: string }) => { - setHighlightedItemId(item.value); - }; - - // Get current color for highlighted item - const selectedWidget = highlightedItemId && highlightedItemId !== 'back' - ? colorableWidgets.find(widget => widget.id === highlightedItemId) - : null; - const currentColor = editingBackground - ? (selectedWidget?.backgroundColor ?? '') // Empty string for 'none' - : (selectedWidget ? (selectedWidget.color ?? (() => { - if (selectedWidget.type !== 'separator' && selectedWidget.type !== 'flex-separator') { - const widgetImpl = getWidget(selectedWidget.type); - return widgetImpl ? widgetImpl.getDefaultColor() : 'white'; - } - return 'white'; - })()) : 'white'); - - const colorList = editingBackground ? bgColors : colors; - const colorIndex = colorList.indexOf(currentColor); - const colorNumber = colorIndex === -1 ? 'custom' : colorIndex + 1; - - let colorDisplay; - if (editingBackground) { - if (!currentColor || currentColor === '') { - colorDisplay = chalk.gray('(no background)'); - } else { - // Determine display name based on format - let displayName; - if (currentColor.startsWith('ansi256:')) { - displayName = `ANSI ${currentColor.substring(8)}`; - } else if (currentColor.startsWith('hex:')) { - displayName = `#${currentColor.substring(4)}`; - } else { - const colorOption = bgColorOptions.find(c => c.value === currentColor); - displayName = colorOption ? colorOption.name : currentColor; - } - - // Apply the color using our applyColors function with the current colorLevel - const level = getColorLevelString(settings.colorLevel); - colorDisplay = applyColors(` ${displayName} `, undefined, currentColor, false, level); - } - } else { - if (!currentColor || currentColor === '') { - colorDisplay = chalk.gray('(default)'); - } else { - // Determine display name based on format - let displayName; - if (currentColor.startsWith('ansi256:')) { - displayName = `ANSI ${currentColor.substring(8)}`; - } else if (currentColor.startsWith('hex:')) { - displayName = `#${currentColor.substring(4)}`; - } else { - const colorOption = colorOptions.find(c => c.value === currentColor); - displayName = colorOption ? colorOption.name : currentColor; - } - - // Apply the color using our applyColors function with the current colorLevel - const level = getColorLevelString(settings.colorLevel); - colorDisplay = applyColors(displayName, currentColor, undefined, false, level); - } - } - - // Show confirmation dialog if clearing all colors - if (showClearConfirm) { - return ( - - ⚠ Confirm Clear All Colors - - This will reset all colors for all widgets to their defaults. - This action cannot be undone! - - - Continue? - - - { - const newItems = clearAllWidgetStyling(widgets); - onUpdate(newItems); - setShowClearConfirm(false); - }} - onCancel={() => { - setShowClearConfirm(false); - }} - /> - - - ); - } - - // Check for global overrides - // Note: When powerline is enabled, background override doesn't affect the display - // since powerline uses item-specific backgrounds for segments - const hasGlobalFgOverride = !!settings.overrideForegroundColor; - const hasGlobalBgOverride = !!settings.overrideBackgroundColor && !powerlineEnabled; - const globalOverrideMessage = hasGlobalFgOverride && hasGlobalBgOverride - ? '⚠ Global override for FG and BG active' - : hasGlobalFgOverride - ? '⚠ Global override for FG active' - : hasGlobalBgOverride - ? '⚠ Global override for BG active' - : null; - - return ( - - - - Configure Colors - {lineIndex !== undefined ? ` - Line ${lineIndex + 1}` : ''} - {editingBackground && chalk.yellow(' [Background Mode]')} - - {globalOverrideMessage && ( - - {'. '} - {globalOverrideMessage} - - )} - - {hexInputMode ? ( - - Enter 6-digit hex color code (without #): - - # - {hexInput} - {hexInput.length < 6 ? '_'.repeat(6 - hexInput.length) : ''} - - - Press Enter when done, ESC to cancel - - ) : ansi256InputMode ? ( - - Enter ANSI 256 color code (0-255): - - {ansi256Input} - {ansi256Input.length === 0 ? '___' : ansi256Input.length === 1 ? '__' : ansi256Input.length === 2 ? '_' : ''} - - - Press Enter when done, ESC to cancel - - ) : ( - <> - - ↑↓ to select, ←→ to cycle - {' '} - {editingBackground ? 'background' : 'foreground'} - , (f) to toggle bg/fg, (b)old, - {settings.colorLevel === 3 ? ' (h)ex,' : settings.colorLevel === 2 ? ' (a)nsi256,' : ''} - {' '} - (r)eset, (c)lear all, ESC to go back - - {!settings.powerline.enabled && !settings.defaultSeparator && ( - - (s)how separators: - {showSeparators ? chalk.green('ON') : chalk.gray('OFF')} - - )} - {selectedWidget ? ( - - - Current - {' '} - {editingBackground ? 'background' : 'foreground'} - {' '} - ( - {colorNumber === 'custom' ? 'custom' : `${colorNumber}/${colorList.length}`} - ): - {' '} - {colorDisplay} - {selectedWidget.bold && chalk.bold(' [BOLD]')} - - - ) : ( - - - - )} - - )} - - {(hexInputMode || ansi256InputMode) ? ( - // Static list when in input mode - no keyboard interaction - - {menuItems.map(item => ( - - {item.value === highlightedItemId ? '▶ ' : ' '} - {item.label} - - ))} - - ) : ( - // Interactive SelectInput when not in input mode - item.value === highlightedItemId))} - indicatorComponent={({ isSelected }) => ( - {isSelected ? '▶' : ' '} - )} - itemComponent={({ isSelected, label }) => ( - // The label already has ANSI codes applied via applyColors() - // We need to pass it directly as a single Text child to preserve the codes - {` ${label}`} - )} - /> - )} - - - ⚠ VSCode Users: - If colors appear incorrect in the VSCode integrated terminal, the "Terminal › Integrated: Minimum Contrast Ratio" (`terminal.integrated.minimumContrastRatio`) setting is forcing a minimum contrast between foreground and background colors. You can adjust this setting to 1 to disable the contrast enforcement, or use a standalone terminal for accurate colors. - - - ); -}; \ No newline at end of file diff --git a/src/tui/components/color-menu/__tests__/mutations.test.ts b/src/tui/components/color-menu/__tests__/mutations.test.ts deleted file mode 100644 index bbd68a13..00000000 --- a/src/tui/components/color-menu/__tests__/mutations.test.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { - describe, - expect, - it -} from 'vitest'; - -import type { WidgetItem } from '../../../../types/Widget'; -import { - clearAllWidgetStyling, - cycleWidgetColor, - resetWidgetStyling, - toggleWidgetBold, - updateWidgetById -} from '../mutations'; - -describe('color-menu mutations', () => { - it('updateWidgetById only updates the matching widget', () => { - const widgets: WidgetItem[] = [ - { id: '1', type: 'tokens-input', color: 'blue' }, - { id: '2', type: 'tokens-output', color: 'white' } - ]; - - const updated = updateWidgetById(widgets, '1', widget => ({ - ...widget, - color: 'red' - })); - - expect(updated[0]?.color).toBe('red'); - expect(updated[1]?.color).toBe('white'); - }); - - it('toggleWidgetBold flips bold state for the selected widget only', () => { - const widgets: WidgetItem[] = [ - { id: '1', type: 'tokens-input', bold: true }, - { id: '2', type: 'tokens-output', bold: false } - ]; - - const updated = toggleWidgetBold(widgets, '1'); - - expect(updated[0]?.bold).toBe(false); - expect(updated[1]?.bold).toBe(false); - }); - - it('resetWidgetStyling removes color, backgroundColor, and bold from one widget', () => { - const widgets: WidgetItem[] = [ - { - id: '1', - type: 'tokens-input', - color: 'red', - backgroundColor: 'blue', - bold: true - }, - { id: '2', type: 'tokens-output', color: 'white', bold: true } - ]; - - const updated = resetWidgetStyling(widgets, '1'); - - expect(updated[0]).toEqual({ id: '1', type: 'tokens-input' }); - expect(updated[1]).toEqual({ id: '2', type: 'tokens-output', color: 'white', bold: true }); - }); - - it('clearAllWidgetStyling strips styling fields from every widget', () => { - const widgets: WidgetItem[] = [ - { - id: '1', - type: 'tokens-input', - color: 'red', - backgroundColor: 'blue', - bold: true - }, - { id: '2', type: 'tokens-output', color: 'white', bold: true } - ]; - - const updated = clearAllWidgetStyling(widgets); - - expect(updated).toEqual([ - { id: '1', type: 'tokens-input' }, - { id: '2', type: 'tokens-output' } - ]); - }); - - it('cycles background colors and maps empty background to undefined', () => { - const widgets: WidgetItem[] = [ - { id: '1', type: 'tokens-input', backgroundColor: 'bg:red' } - ]; - - const right = cycleWidgetColor({ - widgets, - widgetId: '1', - direction: 'right', - editingBackground: true, - colors: ['blue', 'red'], - backgroundColors: ['bg:red', ''] - }); - const left = cycleWidgetColor({ - widgets: right, - widgetId: '1', - direction: 'left', - editingBackground: true, - colors: ['blue', 'red'], - backgroundColors: ['bg:red', ''] - }); - - expect(right[0]?.backgroundColor).toBeUndefined(); - expect(left[0]?.backgroundColor).toBe('bg:red'); - }); - - it('cycles foreground colors from widget default and treats dim as default', () => { - const fromDefault: WidgetItem[] = [ - { id: '1', type: 'tokens-input' } - ]; - const fromDim: WidgetItem[] = [ - { id: '1', type: 'tokens-input', color: 'dim' } - ]; - - const defaultCycle = cycleWidgetColor({ - widgets: fromDefault, - widgetId: '1', - direction: 'right', - editingBackground: false, - colors: ['blue', 'red'], - backgroundColors: ['bg:red', ''] - }); - const dimCycle = cycleWidgetColor({ - widgets: fromDim, - widgetId: '1', - direction: 'right', - editingBackground: false, - colors: ['blue', 'red'], - backgroundColors: ['bg:red', ''] - }); - - expect(defaultCycle[0]?.color).toBe('red'); - expect(dimCycle[0]?.color).toBe('red'); - }); -}); \ No newline at end of file diff --git a/src/tui/components/color-menu/mutations.ts b/src/tui/components/color-menu/mutations.ts deleted file mode 100644 index 1556d4b1..00000000 --- a/src/tui/components/color-menu/mutations.ts +++ /dev/null @@ -1,148 +0,0 @@ -import type { WidgetItem } from '../../../types/Widget'; -import { getWidget } from '../../../utils/widgets'; - -export function updateWidgetById( - widgets: WidgetItem[], - widgetId: string, - updater: (widget: WidgetItem) => WidgetItem -): WidgetItem[] { - return widgets.map(widget => widget.id === widgetId ? updater(widget) : widget); -} - -export function setWidgetColor( - widgets: WidgetItem[], - widgetId: string, - color: string, - editingBackground: boolean -): WidgetItem[] { - return updateWidgetById(widgets, widgetId, (widget) => { - if (editingBackground) { - return { - ...widget, - backgroundColor: color - }; - } - - return { - ...widget, - color - }; - }); -} - -export function toggleWidgetBold(widgets: WidgetItem[], widgetId: string): WidgetItem[] { - return updateWidgetById(widgets, widgetId, widget => ({ - ...widget, - bold: !widget.bold - })); -} - -export function resetWidgetStyling(widgets: WidgetItem[], widgetId: string): WidgetItem[] { - return updateWidgetById(widgets, widgetId, (widget) => { - const { - color, - backgroundColor, - bold, - ...restWidget - } = widget; - void color; // Intentionally unused - void backgroundColor; // Intentionally unused - void bold; // Intentionally unused - return restWidget; - }); -} - -export function clearAllWidgetStyling(widgets: WidgetItem[]): WidgetItem[] { - return widgets.map((widget) => { - const { - color, - backgroundColor, - bold, - ...restWidget - } = widget; - void color; // Intentionally unused - void backgroundColor; // Intentionally unused - void bold; // Intentionally unused - return restWidget; - }); -} - -function getDefaultForegroundColor(widget: WidgetItem): string { - if (widget.type === 'separator' || widget.type === 'flex-separator') { - return 'white'; - } - - const widgetImpl = getWidget(widget.type); - return widgetImpl ? widgetImpl.getDefaultColor() : 'white'; -} - -function getNextIndex(currentIndex: number, length: number, direction: 'left' | 'right'): number { - if (direction === 'right') { - return (currentIndex + 1) % length; - } - - return currentIndex === 0 ? length - 1 : currentIndex - 1; -} - -export interface CycleWidgetColorOptions { - widgets: WidgetItem[]; - widgetId: string; - direction: 'left' | 'right'; - editingBackground: boolean; - colors: string[]; - backgroundColors: string[]; -} - -export function cycleWidgetColor({ - widgets, - widgetId, - direction, - editingBackground, - colors, - backgroundColors -}: CycleWidgetColorOptions): WidgetItem[] { - return updateWidgetById(widgets, widgetId, (widget) => { - if (editingBackground) { - if (backgroundColors.length === 0) { - return widget; - } - - const currentBgColor = widget.backgroundColor ?? ''; - let currentBgColorIndex = backgroundColors.indexOf(currentBgColor); - if (currentBgColorIndex === -1) { - currentBgColorIndex = 0; - } - - const nextBgColorIndex = getNextIndex(currentBgColorIndex, backgroundColors.length, direction); - const nextBgColor = backgroundColors[nextBgColorIndex]; - - return { - ...widget, - backgroundColor: nextBgColor === '' ? undefined : nextBgColor - }; - } - - if (colors.length === 0) { - return widget; - } - - const defaultColor = getDefaultForegroundColor(widget); - let currentColor = widget.color ?? defaultColor; - if (currentColor === 'dim') { - currentColor = defaultColor; - } - - let currentColorIndex = colors.indexOf(currentColor); - if (currentColorIndex === -1) { - currentColorIndex = 0; - } - - const nextColorIndex = getNextIndex(currentColorIndex, colors.length, direction); - const nextColor = colors[nextColorIndex]; - - return { - ...widget, - color: nextColor - }; - }); -} \ No newline at end of file diff --git a/src/tui/components/index.ts b/src/tui/components/index.ts index 34afc2e6..d19ba943 100644 --- a/src/tui/components/index.ts +++ b/src/tui/components/index.ts @@ -1,5 +1,4 @@ // Barrel file - exports all components and their types -export * from './ColorMenu'; export * from './ConfirmDialog'; export * from './GlobalOverridesMenu'; export * from './InstallMenu'; From 252c762235156d90200c33ac7ed1cd5941d5f48f Mon Sep 17 00:00:00 2001 From: Mark O'Keefe Date: Mon, 23 Mar 2026 19:55:48 +1100 Subject: [PATCH 15/42] chore: update stale ColorMenu references in comments Co-Authored-By: Claude Opus 4.6 (1M context) --- src/tui/components/RulesEditor.tsx | 6 +++--- src/tui/components/color-editor/input-handlers.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/tui/components/RulesEditor.tsx b/src/tui/components/RulesEditor.tsx index f7e9ee6e..47931e2b 100644 --- a/src/tui/components/RulesEditor.tsx +++ b/src/tui/components/RulesEditor.tsx @@ -330,7 +330,7 @@ export const RulesEditor: React.FC = ({ widget, settings, onUp } }); - // Get widget display name (same as ColorMenu does) + // Get widget display name const getWidgetDisplayName = () => { const widgetImpl = getWidget(widget.type); return widgetImpl ? widgetImpl.getDisplayName() : widget.type; @@ -636,7 +636,7 @@ export const RulesEditor: React.FC = ({ widget, settings, onUp const stopIndicator = rule.stop ? ' (stop)' : ''; const appliedProps = formatAppliedProperties(rule.apply); - // Get widget display name (same as ColorMenu pattern) + // Get widget display name const displayName = getWidgetDisplayName(); // Get widget's actual configured color/bold as base @@ -650,7 +650,7 @@ export const RulesEditor: React.FC = ({ widget, settings, onUp const backgroundColor = rule.apply.backgroundColor ? String(rule.apply.backgroundColor) : baseBackgroundColor; const bold = rule.apply.bold !== undefined ? Boolean(rule.apply.bold) : baseBold; - // Apply colors (same as ColorMenu does) + // Apply colors const colorLevel = getColorLevelString(settings.colorLevel); const styledLabel = applyColors(displayName, color, backgroundColor, bold, colorLevel); diff --git a/src/tui/components/color-editor/input-handlers.ts b/src/tui/components/color-editor/input-handlers.ts index 38cadddc..600bc4c5 100644 --- a/src/tui/components/color-editor/input-handlers.ts +++ b/src/tui/components/color-editor/input-handlers.ts @@ -53,7 +53,7 @@ function cycleColor( } /** - * Shared color input handler for both ColorMenu and RulesEditor + * Shared color input handler for ItemsEditor and RulesEditor * Returns true if input was handled, false otherwise */ export function handleColorInput({ From fc1c86c1cf3e2828d0a4883675b977d7c278707e Mon Sep 17 00:00:00 2001 From: Mark O'Keefe Date: Mon, 23 Mar 2026 21:05:37 +1100 Subject: [PATCH 16/42] feat: extract rule-level input handlers and formatting into rules-editor module Create dedicated files for rule property input handling and condition/property formatting, extracted from RulesEditor.tsx for reuse by ItemsEditor's accordion mode. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/tui/components/rules-editor/formatting.ts | 131 ++++++++++++++++++ .../components/rules-editor/input-handlers.ts | 122 ++++++++++++++++ 2 files changed, 253 insertions(+) create mode 100644 src/tui/components/rules-editor/formatting.ts create mode 100644 src/tui/components/rules-editor/input-handlers.ts diff --git a/src/tui/components/rules-editor/formatting.ts b/src/tui/components/rules-editor/formatting.ts new file mode 100644 index 00000000..386d3483 --- /dev/null +++ b/src/tui/components/rules-editor/formatting.ts @@ -0,0 +1,131 @@ +import { + DISPLAY_OPERATOR_LABELS, + OPERATOR_LABELS, + getConditionNot, + getConditionOperator, + getConditionValue, + getConditionWidget, + getDisplayOperator, + isBooleanOperator, + isSetOperator, + isStringOperator +} from '../../../types/Condition'; +import type { WidgetItem } from '../../../types/Widget'; +import { mergeWidgetWithRuleApply } from '../../../utils/widget-properties'; +import { getWidget } from '../../../utils/widgets'; + +/** + * Format a rule condition into a human-readable summary string. + * + * Handles display operators (notEquals, notContains, etc.) and base operators + * with optional NOT prefix. + */ +export function formatCondition(when: Record): string { + const widgetRef = getConditionWidget(when); + const operator = getConditionOperator(when); + const value = getConditionValue(when); + const notFlag = getConditionNot(when); + + // Get widget display name + let widgetName = 'self'; + if (widgetRef !== 'self') { + const widgetImpl = getWidget(widgetRef); + widgetName = widgetImpl ? widgetImpl.getDisplayName() : widgetRef; + } + + // Check if this matches a display operator pattern + const displayOp = getDisplayOperator(when); + if (displayOp) { + const displayLabel = DISPLAY_OPERATOR_LABELS[displayOp]; + + // Format based on display operator type + if (displayOp === 'notEquals') { + if (typeof value === 'string') { + return `when ${widgetName} ${displayLabel} "${value}"`; + } + return `when ${widgetName} ${displayLabel} ${value}`; + } + if (displayOp === 'notContains' || displayOp === 'notStartsWith' || displayOp === 'notEndsWith') { + return `when ${widgetName} ${displayLabel} "${value}"`; + } + return `when ${widgetName} ${displayLabel}`; + } + + // Fall back to showing base operator with NOT prefix if needed + const notPrefix = notFlag ? 'NOT ' : ''; + + if (operator && value !== null) { + const opLabel = OPERATOR_LABELS[operator]; + + // Format based on operator type + if (isStringOperator(operator)) { + return `when ${notPrefix}${widgetName} ${opLabel} "${value}"`; + } + + if (isBooleanOperator(operator)) { + return `when ${notPrefix}${widgetName} ${opLabel}`; + } + + if (isSetOperator(operator) && Array.isArray(value)) { + const valueList = value.map(v => JSON.stringify(v)).join(', '); + return `when ${notPrefix}${widgetName} ${opLabel} [${valueList}]`; + } + + // Numeric or equals + return `when ${notPrefix}${widgetName} ${opLabel}${value}`; + } + + return `when ${JSON.stringify(when)}`; +} + +/** + * Format applied properties as labels using the widget's own display logic. + * + * Creates a temp widget by merging base widget with rule.apply, then uses + * the widget's getEditorDisplay to format modifier text alongside base + * property labels (raw value, merge, hidden, character). + */ +export function formatAppliedProperties( + apply: Record, + baseWidget: WidgetItem +): string { + // Create a temp widget by merging base widget with rule.apply + // Use shared merge function to handle metadata deep merge correctly + const tempWidget = mergeWidgetWithRuleApply(baseWidget, apply); + + // Let the widget format its own modifiers (hide, remaining, etc.) + const widgetImpl = getWidget(baseWidget.type); + const { modifierText } = widgetImpl?.getEditorDisplay(tempWidget) ?? { modifierText: undefined }; + + // Build labels for base properties (rawValue, merge) + const baseLabels: string[] = []; + + if (tempWidget.rawValue) { + baseLabels.push('raw value'); + } + + if (tempWidget.merge === true) { + baseLabels.push('merged→'); + } else if (tempWidget.merge === 'no-padding') { + baseLabels.push('merged-no-pad→'); + } + + if (tempWidget.hide) { + baseLabels.push('hidden'); + } + + if (tempWidget.character !== undefined) { + baseLabels.push(`character: ${tempWidget.character}`); + } + + // Combine widget-specific modifiers and base property labels + const parts: string[] = []; + if (modifierText) { + parts.push(modifierText); + } + if (baseLabels.length > 0) { + parts.push(...baseLabels.map(l => `(${l})`)); + } + + return parts.length > 0 ? ` ${parts.join(' ')}` : ''; +} \ No newline at end of file diff --git a/src/tui/components/rules-editor/input-handlers.ts b/src/tui/components/rules-editor/input-handlers.ts new file mode 100644 index 00000000..93656217 --- /dev/null +++ b/src/tui/components/rules-editor/input-handlers.ts @@ -0,0 +1,122 @@ +import type { + CustomKeybind, + Widget, + WidgetItem +} from '../../../types/Widget'; +import type { InputKey } from '../../../utils/input-guards'; +import { + extractWidgetOverrides, + mergeWidgetWithRuleApply +} from '../../../utils/widget-properties'; +import { + handleWidgetPropertyInput, + type CustomEditorWidgetState +} from '../items-editor/input-handlers'; + +export type { InputKey }; + +export interface HandleRulePropertyInputArgs { + input: string; + key: InputKey; + baseWidget: WidgetItem; + rule: { when: Record; apply: Record; stop?: boolean }; + ruleIndex: number; + onUpdate: (updatedWidget: WidgetItem) => void; + getCustomKeybindsForWidget: (widgetImpl: Widget, widget: WidgetItem) => CustomKeybind[]; + setCustomEditorWidget?: (state: CustomEditorWidgetState | null) => void; +} + +/** + * Handle rule-specific property input keys: s (toggle stop), h (toggle hide), c (clear properties). + * After handling rule-specific keys, delegates to the shared handleWidgetPropertyInput + * for r (raw value), m (merge), and custom keybinds. + * + * All updates go through extractWidgetOverrides to store only diffs in rule.apply. + */ +export function handleRulePropertyInput({ + input, + key, + baseWidget, + rule, + ruleIndex, + onUpdate, + getCustomKeybindsForWidget, + setCustomEditorWidget +}: HandleRulePropertyInputArgs): boolean { + const rules = baseWidget.rules ?? []; + + // Toggle stop flag + if (input === 's') { + const newRules = [...rules]; + newRules[ruleIndex] = { + ...rule, + stop: !rule.stop + }; + onUpdate({ ...baseWidget, rules: newRules }); + return true; + } + + // Toggle hide flag + if (input === 'h') { + const tempWidget = mergeWidgetWithRuleApply(baseWidget, rule.apply); + const updatedWidget = { ...tempWidget, hide: !tempWidget.hide }; + + const newApply = extractWidgetOverrides(updatedWidget, baseWidget, rule.apply); + const newRules = [...rules]; + newRules[ruleIndex] = { + ...rule, + apply: newApply + }; + onUpdate({ ...baseWidget, rules: newRules }); + return true; + } + + // Clear property overrides (preserve color/backgroundColor/bold/hide) + if (input === 'c') { + const newRules = [...rules]; + const { color, backgroundColor, bold, hide, ...restApply } = rule.apply; + + const newApply: Record = {}; + if (color !== undefined) { + newApply.color = color; + } + if (backgroundColor !== undefined) { + newApply.backgroundColor = backgroundColor; + } + if (bold !== undefined) { + newApply.bold = bold; + } + if (hide !== undefined) { + newApply.hide = hide; + } + + void restApply; // All other properties are cleared + + newRules[ruleIndex] = { + ...rule, + apply: newApply + }; + onUpdate({ ...baseWidget, rules: newRules }); + return true; + } + + // Delegate to shared widget property input handler (r, m, custom keybinds) + const tempWidget = mergeWidgetWithRuleApply(baseWidget, rule.apply); + + return handleWidgetPropertyInput({ + input, + key, + widget: tempWidget, + onUpdate: (updatedWidget) => { + const newApply = extractWidgetOverrides(updatedWidget, baseWidget, rule.apply); + const newRules = [...rules]; + newRules[ruleIndex] = { + ...rule, + apply: newApply + }; + onUpdate({ ...baseWidget, rules: newRules }); + }, + getCustomKeybindsForWidget, + setCustomEditorWidget + }); +} \ No newline at end of file From 74878b64070a0d26f70f1762e85e0b6de1d4c59b Mon Sep 17 00:00:00 2001 From: Mark O'Keefe Date: Mon, 23 Mar 2026 21:08:09 +1100 Subject: [PATCH 17/42] feat: add rule color, move, add/delete, and editor complete handlers Add remaining rule-level input handlers to rules-editor/input-handlers.ts: - handleRuleColorInput: wraps shared handleColorInput with rule-aware onUpdate/onReset callbacks that route through extractWidgetOverrides - handleRuleMoveMode: swap rules on arrow keys, exit on Enter/ESC - addRule/deleteRule: CRUD with selection adjustment - handleRuleEditorComplete: custom editor completion routed through extractWidgetOverrides for rule.apply diff storage Co-Authored-By: Claude Opus 4.6 (1M context) --- .../components/rules-editor/input-handlers.ts | 238 ++++++++++++++++++ 1 file changed, 238 insertions(+) diff --git a/src/tui/components/rules-editor/input-handlers.ts b/src/tui/components/rules-editor/input-handlers.ts index 93656217..3a0a1d68 100644 --- a/src/tui/components/rules-editor/input-handlers.ts +++ b/src/tui/components/rules-editor/input-handlers.ts @@ -1,3 +1,4 @@ +import type { Settings } from '../../../types/Settings'; import type { CustomKeybind, Widget, @@ -8,6 +9,10 @@ import { extractWidgetOverrides, mergeWidgetWithRuleApply } from '../../../utils/widget-properties'; +import { + handleColorInput, + type ColorEditorState +} from '../color-editor/input-handlers'; import { handleWidgetPropertyInput, type CustomEditorWidgetState @@ -119,4 +124,237 @@ export function handleRulePropertyInput({ getCustomKeybindsForWidget, setCustomEditorWidget }); +} + +// --- Color mode handler --- + +export interface HandleRuleColorInputArgs { + input: string; + key: InputKey; + baseWidget: WidgetItem; + rule: { when: Record; apply: Record; stop?: boolean }; + ruleIndex: number; + settings: Settings; + colorEditorState: ColorEditorState; + setColorEditorState: (updater: (prev: ColorEditorState) => ColorEditorState) => void; + onUpdate: (updatedWidget: WidgetItem) => void; +} + +/** + * Handle color mode input for a rule. + * Creates a temp widget via mergeWidgetWithRuleApply, delegates to shared handleColorInput, + * and routes onUpdate/onReset callbacks through extractWidgetOverrides. + * + * The onReset callback resets to BASE WIDGET colors (widget.color, widget.backgroundColor, + * widget.bold), not removing them entirely — this differs from widget-level color reset. + */ +export function handleRuleColorInput({ + input, + key, + baseWidget, + rule, + ruleIndex, + settings, + colorEditorState, + setColorEditorState, + onUpdate +}: HandleRuleColorInputArgs): boolean { + const rules = baseWidget.rules ?? []; + + // Create temp widget by merging base + apply + const tempWidget = mergeWidgetWithRuleApply(baseWidget, rule.apply); + + // Use shared color input handler + return handleColorInput({ + input, + key, + widget: tempWidget, + settings, + state: colorEditorState, + setState: setColorEditorState, + onUpdate: (updatedWidget) => { + // Extract what changed compared to base widget + const newApply = extractWidgetOverrides(updatedWidget, baseWidget, rule.apply); + const newRules = [...rules]; + newRules[ruleIndex] = { + ...rule, + apply: newApply + }; + onUpdate({ ...baseWidget, rules: newRules }); + }, + onReset: () => { + // Reset colors to base widget (remove color/backgroundColor/bold from apply) + const resetWidget = { + ...tempWidget, + color: baseWidget.color, + backgroundColor: baseWidget.backgroundColor, + bold: baseWidget.bold + }; + const newApply = extractWidgetOverrides(resetWidget, baseWidget, rule.apply); + const newRules = [...rules]; + newRules[ruleIndex] = { + ...rule, + apply: newApply + }; + onUpdate({ ...baseWidget, rules: newRules }); + } + }); +} + +// --- Move mode handler --- + +export interface HandleRuleMoveModeArgs { + key: { upArrow?: boolean; downArrow?: boolean; return?: boolean; escape?: boolean }; + baseWidget: WidgetItem; + selectedIndex: number; + setSelectedIndex: (index: number) => void; + setMoveMode: (moveMode: boolean) => void; + onUpdate: (updatedWidget: WidgetItem) => void; +} + +/** + * Handle move mode input: swap rules on up/down arrows, exit on Enter/ESC. + */ +export function handleRuleMoveMode({ + key, + baseWidget, + selectedIndex, + setSelectedIndex, + setMoveMode, + onUpdate +}: HandleRuleMoveModeArgs): void { + const rules = baseWidget.rules ?? []; + + if (key.upArrow && selectedIndex > 0) { + // Swap with rule above + const newRules = [...rules]; + const currentRule = newRules[selectedIndex]; + const previousRule = newRules[selectedIndex - 1]; + if (!currentRule || !previousRule) { + return; + } + newRules[selectedIndex] = previousRule; + newRules[selectedIndex - 1] = currentRule; + + onUpdate({ ...baseWidget, rules: newRules }); + setSelectedIndex(selectedIndex - 1); + } else if (key.downArrow && selectedIndex < rules.length - 1) { + // Swap with rule below + const newRules = [...rules]; + const currentRule = newRules[selectedIndex]; + const nextRule = newRules[selectedIndex + 1]; + if (!currentRule || !nextRule) { + return; + } + newRules[selectedIndex] = nextRule; + newRules[selectedIndex + 1] = currentRule; + + onUpdate({ ...baseWidget, rules: newRules }); + setSelectedIndex(selectedIndex + 1); + } else if (key.escape || key.return) { + setMoveMode(false); + } +} + +// --- Add/Delete rule functions --- + +export interface AddRuleArgs { + baseWidget: WidgetItem; + setSelectedIndex: (index: number) => void; + onUpdate: (updatedWidget: WidgetItem) => void; +} + +/** + * Add a new rule with placeholder condition { greaterThan: 50 } and empty apply. + * Selects the newly added rule. + */ +export function addRule({ + baseWidget, + setSelectedIndex, + onUpdate +}: AddRuleArgs): void { + const rules = baseWidget.rules ?? []; + + const newRule = { + when: { greaterThan: 50 }, + apply: {}, + stop: false + }; + + const newRules = [...rules, newRule]; + onUpdate({ ...baseWidget, rules: newRules }); + setSelectedIndex(newRules.length - 1); +} + +export interface DeleteRuleArgs { + baseWidget: WidgetItem; + selectedIndex: number; + setSelectedIndex: (index: number) => void; + onUpdate: (updatedWidget: WidgetItem) => void; +} + +/** + * Delete the rule at selectedIndex. Adjusts selection after delete: + * if selectedIndex >= newRules.length, decrements by 1. + */ +export function deleteRule({ + baseWidget, + selectedIndex, + setSelectedIndex, + onUpdate +}: DeleteRuleArgs): void { + const rules = baseWidget.rules ?? []; + + if (rules.length === 0) { + return; + } + + const newRules = rules.filter((_, i) => i !== selectedIndex); + onUpdate({ ...baseWidget, rules: newRules }); + + // Adjust selection after delete (same pattern as ItemsEditor) + if (selectedIndex >= newRules.length && selectedIndex > 0) { + setSelectedIndex(selectedIndex - 1); + } +} + +// --- Custom editor completion handler --- + +export interface HandleRuleEditorCompleteArgs { + updatedWidget: WidgetItem; + baseWidget: WidgetItem; + selectedIndex: number; + onUpdate: (updatedWidget: WidgetItem) => void; + setCustomEditorWidget: (state: CustomEditorWidgetState | null) => void; +} + +/** + * Handle custom editor widget completion while rules are expanded. + * Routes the completed widget through extractWidgetOverrides so only + * the diff is stored in rule.apply. + */ +export function handleRuleEditorComplete({ + updatedWidget, + baseWidget, + selectedIndex, + onUpdate, + setCustomEditorWidget +}: HandleRuleEditorCompleteArgs): void { + const rules = baseWidget.rules ?? []; + const rule = rules[selectedIndex]; + + if (!rule) { + setCustomEditorWidget(null); + return; + } + + // Extract what changed compared to base widget + const newApply = extractWidgetOverrides(updatedWidget, baseWidget, rule.apply); + const newRules = [...rules]; + newRules[selectedIndex] = { + ...rule, + apply: newApply + }; + onUpdate({ ...baseWidget, rules: newRules }); + setCustomEditorWidget(null); } \ No newline at end of file From b7d8102ca08b2c94fd82d0c38865f7d5269a3f5c Mon Sep 17 00:00:00 2001 From: Mark O'Keefe Date: Mon, 23 Mar 2026 21:12:17 +1100 Subject: [PATCH 18/42] feat: add rule-level state and toggle x key to accordion expansion Replace the RulesEditor overlay with inline accordion state management. The x key now toggles expandedWidgetId (by widget id, survives reordering) and resets all rule-level state on expansion. Remove RulesEditor import and overlay rendering block. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/tui/components/ItemsEditor.tsx | 68 ++++++++++++------- .../__tests__/input-handlers.test.ts | 10 +-- .../components/items-editor/input-handlers.ts | 6 +- 3 files changed, 50 insertions(+), 34 deletions(-) diff --git a/src/tui/components/ItemsEditor.tsx b/src/tui/components/ItemsEditor.tsx index 6e0011d3..364cf31c 100644 --- a/src/tui/components/ItemsEditor.tsx +++ b/src/tui/components/ItemsEditor.tsx @@ -27,7 +27,6 @@ import { } from '../../utils/widgets'; import { ConfirmDialog } from './ConfirmDialog'; -import { RulesEditor } from './RulesEditor'; import { getCurrentColorInfo, handleColorInput, @@ -57,7 +56,27 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB const [customEditorWidget, setCustomEditorWidget] = useState(null); const [widgetPicker, setWidgetPicker] = useState(null); const [showClearConfirm, setShowClearConfirm] = useState(false); - const [rulesEditorWidget, setRulesEditorWidget] = useState(null); + const [expandedWidgetId, setExpandedWidgetId] = useState(null); + const [ruleSelectedIndex, setRuleSelectedIndex] = useState(0); + const [ruleEditorMode, setRuleEditorMode] = useState<'property' | 'color'>('property'); + const [ruleColorEditorState, setRuleColorEditorState] = useState({ + editingBackground: false, + hexInputMode: false, + hexInput: '', + ansi256InputMode: false, + ansi256Input: '' + }); + const [ruleMoveMode, setRuleMoveMode] = useState(false); + const [ruleConditionEditorIndex, setRuleConditionEditorIndex] = useState(null); + + // Rule-level state is consumed by accordion rendering (Task 2.3) and input routing (Task 2.2). + // Reference values here to satisfy the linter until those tasks wire them into rendering/input. + void expandedWidgetId; + void ruleSelectedIndex; + void ruleEditorMode; + void ruleColorEditorState; + void ruleMoveMode; + void ruleConditionEditorIndex; const [editorMode, setEditorMode] = useState<'items' | 'color'>('items'); const [colorEditorState, setColorEditorState] = useState({ editingBackground: false, @@ -113,6 +132,25 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB setCustomEditorWidget(null); }; + const toggleRulesExpansion = (widget: WidgetItem) => { + if (expandedWidgetId === widget.id) { + setExpandedWidgetId(null); + } else { + setExpandedWidgetId(widget.id); + setRuleSelectedIndex(0); + setRuleEditorMode('property'); + setRuleColorEditorState({ + editingBackground: false, + hexInputMode: false, + hexInput: '', + ansi256InputMode: false, + ansi256Input: '' + }); + setRuleMoveMode(false); + setRuleConditionEditorIndex(null); + } + }; + const getCustomKeybindsForWidget = (widgetImpl: Widget, widget: WidgetItem): CustomKeybind[] => { if (!widgetImpl.getCustomKeybinds) { return []; @@ -176,18 +214,13 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB return; } - // Skip input if rules editor is active - if (rulesEditorWidget) { - return; - } - // Skip input handling when clear confirmation is active - let ConfirmDialog handle it if (showClearConfirm) { return; } // Tab toggles between items and color mode — always consumed here, never falls through - // Only when: widgets exist, no overlay active (picker, move mode, custom editor, rules editor, clear confirm) + // Only when: widgets exist, no overlay active (picker, move mode, custom editor, clear confirm) if (key.tab && widgets.length > 0 && !widgetPicker && !moveMode) { const widget = widgets[selectedIndex]; if (widget) { @@ -318,7 +351,7 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB openWidgetPicker, getCustomKeybindsForWidget, setCustomEditorWidget, - setRulesEditorWidget + toggleRulesExpansion }); }); @@ -455,23 +488,6 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB }); } - if (rulesEditorWidget) { - return ( - { - // Update widget in widgets array - const newWidgets = widgets.map(w => w.id === updatedWidget.id ? updatedWidget : w - ); - onUpdate(newWidgets); // This triggers preview update! - setRulesEditorWidget(updatedWidget); // Keep editor in sync - }} - onBack={() => { setRulesEditorWidget(null); }} - /> - ); - } - if (showClearConfirm) { return ( diff --git a/src/tui/components/items-editor/__tests__/input-handlers.test.ts b/src/tui/components/items-editor/__tests__/input-handlers.test.ts index 1a068043..1a8af85c 100644 --- a/src/tui/components/items-editor/__tests__/input-handlers.test.ts +++ b/src/tui/components/items-editor/__tests__/input-handlers.test.ts @@ -168,7 +168,7 @@ describe('items-editor input handlers', () => { openWidgetPicker: vi.fn(), getCustomKeybindsForWidget: (widgetImpl, widget) => widgetImpl.getCustomKeybinds ? widgetImpl.getCustomKeybinds(widget) : [], setCustomEditorWidget: vi.fn(), - setRulesEditorWidget: vi.fn() + toggleRulesExpansion: vi.fn() }); const updated = onUpdate.mock.calls[0]?.[0] as WidgetItem[] | undefined; @@ -195,7 +195,7 @@ describe('items-editor input handlers', () => { openWidgetPicker: vi.fn(), getCustomKeybindsForWidget: (widgetImpl, widget) => widgetImpl.getCustomKeybinds ? widgetImpl.getCustomKeybinds(widget) : [], setCustomEditorWidget: vi.fn(), - setRulesEditorWidget: vi.fn() + toggleRulesExpansion: vi.fn() }); const updated = onUpdate.mock.calls[0]?.[0] as WidgetItem[] | undefined; @@ -222,7 +222,7 @@ describe('items-editor input handlers', () => { openWidgetPicker: vi.fn(), getCustomKeybindsForWidget: (widgetImpl, widget) => widgetImpl.getCustomKeybinds ? widgetImpl.getCustomKeybinds(widget) : [], setCustomEditorWidget: vi.fn(), - setRulesEditorWidget: vi.fn() + toggleRulesExpansion: vi.fn() }); const updated = onUpdate.mock.calls[0]?.[0] as WidgetItem[] | undefined; @@ -249,7 +249,7 @@ describe('items-editor input handlers', () => { openWidgetPicker: vi.fn(), getCustomKeybindsForWidget: (widgetImpl, widget) => widgetImpl.getCustomKeybinds ? widgetImpl.getCustomKeybinds(widget) : [], setCustomEditorWidget: vi.fn(), - setRulesEditorWidget: vi.fn() + toggleRulesExpansion: vi.fn() }); const updated = onUpdate.mock.calls[0]?.[0] as WidgetItem[] | undefined; @@ -277,7 +277,7 @@ describe('items-editor input handlers', () => { openWidgetPicker: vi.fn(), getCustomKeybindsForWidget: (widgetImpl, widget) => widgetImpl.getCustomKeybinds ? widgetImpl.getCustomKeybinds(widget) : [], setCustomEditorWidget, - setRulesEditorWidget: vi.fn() + toggleRulesExpansion: vi.fn() }); expect(onUpdate).not.toHaveBeenCalled(); diff --git a/src/tui/components/items-editor/input-handlers.ts b/src/tui/components/items-editor/input-handlers.ts index ff01074d..c066ce03 100644 --- a/src/tui/components/items-editor/input-handlers.ts +++ b/src/tui/components/items-editor/input-handlers.ts @@ -402,7 +402,7 @@ export interface HandleNormalInputModeArgs { openWidgetPicker: (action: WidgetPickerAction) => void; getCustomKeybindsForWidget: (widgetImpl: Widget, widget: WidgetItem) => CustomKeybind[]; setCustomEditorWidget: (state: CustomEditorWidgetState | null) => void; - setRulesEditorWidget: (widget: WidgetItem | null) => void; + toggleRulesExpansion: (widget: WidgetItem) => void; } export function handleNormalInputMode({ @@ -419,7 +419,7 @@ export function handleNormalInputMode({ openWidgetPicker, getCustomKeybindsForWidget, setCustomEditorWidget, - setRulesEditorWidget + toggleRulesExpansion }: HandleNormalInputModeArgs): void { if (key.upArrow && widgets.length > 0) { setSelectedIndex(Math.max(0, selectedIndex - 1)); @@ -456,7 +456,7 @@ export function handleNormalInputMode({ } else if (input === 'x' && widgets.length > 0) { const currentWidget = widgets[selectedIndex]; if (currentWidget && currentWidget.type !== 'separator' && currentWidget.type !== 'flex-separator') { - setRulesEditorWidget(currentWidget); + toggleRulesExpansion(currentWidget); } } else if (key.escape) { onBack(); From f5d673ae6abb29891e687c5bdecf638eba5781fd Mon Sep 17 00:00:00 2001 From: Mark O'Keefe Date: Mon, 23 Mar 2026 21:16:13 +1100 Subject: [PATCH 19/42] feat: add rule-level input routing in ItemsEditor useInput handler Route keyboard input to rule-level handlers when expandedWidgetId is set. Handles condition editor passthrough, rule move mode, rule color mode with ESC peeling, and rule property mode with add/delete/condition editor keys. Updated handleEditorComplete to route through handleRuleEditorComplete when rules are expanded. No fallthrough to widget-level handlers. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/tui/components/ItemsEditor.tsx | 231 ++++++++++++++++++++++++++++- 1 file changed, 223 insertions(+), 8 deletions(-) diff --git a/src/tui/components/ItemsEditor.tsx b/src/tui/components/ItemsEditor.tsx index 364cf31c..ecc92829 100644 --- a/src/tui/components/ItemsEditor.tsx +++ b/src/tui/components/ItemsEditor.tsx @@ -41,6 +41,14 @@ import { type WidgetPickerAction, type WidgetPickerState } from './items-editor/input-handlers'; +import { + addRule, + deleteRule, + handleRuleColorInput, + handleRuleEditorComplete, + handleRuleMoveMode, + handleRulePropertyInput +} from './rules-editor/input-handlers'; export interface ItemsEditorProps { widgets: WidgetItem[]; @@ -69,14 +77,6 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB const [ruleMoveMode, setRuleMoveMode] = useState(false); const [ruleConditionEditorIndex, setRuleConditionEditorIndex] = useState(null); - // Rule-level state is consumed by accordion rendering (Task 2.3) and input routing (Task 2.2). - // Reference values here to satisfy the linter until those tasks wire them into rendering/input. - void expandedWidgetId; - void ruleSelectedIndex; - void ruleEditorMode; - void ruleColorEditorState; - void ruleMoveMode; - void ruleConditionEditorIndex; const [editorMode, setEditorMode] = useState<'items' | 'color'>('items'); const [colorEditorState, setColorEditorState] = useState({ editingBackground: false, @@ -122,6 +122,27 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB }; const handleEditorComplete = (updatedWidget: WidgetItem) => { + if (expandedWidgetId) { + // Rules are expanded — route through rule editor complete + const expandedWidget = widgets.find(w => w.id === expandedWidgetId); + if (expandedWidget) { + handleRuleEditorComplete({ + updatedWidget, + baseWidget: expandedWidget, + selectedIndex: ruleSelectedIndex, + onUpdate: (updated) => { + const newWidgets = [...widgets]; + const widgetIndex = widgets.findIndex(w => w.id === expandedWidgetId); + if (widgetIndex !== -1) { + newWidgets[widgetIndex] = updated; + onUpdate(newWidgets); + } + }, + setCustomEditorWidget + }); + return; + } + } const newWidgets = [...widgets]; newWidgets[selectedIndex] = updatedWidget; onUpdate(newWidgets); @@ -269,6 +290,200 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB } } + // Rule-level input routing — when rules are expanded for a widget + if (expandedWidgetId !== null) { + const expandedWidget = widgets.find(w => w.id === expandedWidgetId); + if (!expandedWidget) { + setExpandedWidgetId(null); + return; + } + + const rules = expandedWidget.rules ?? []; + const currentRule = rules[ruleSelectedIndex]; + + const updateExpandedWidget = (updatedWidget: WidgetItem) => { + const newWidgets = [...widgets]; + const widgetIndex = widgets.findIndex(w => w.id === expandedWidgetId); + if (widgetIndex !== -1) { + newWidgets[widgetIndex] = updatedWidget; + onUpdate(newWidgets); + } + }; + + // 1. Condition editor active — let it handle input + if (ruleConditionEditorIndex !== null) { + return; + } + + // 2. Custom editor widget is already guarded at the top of useInput, + // so it is always null here. No additional check needed. + + // 3. Rule move mode + if (ruleMoveMode) { + handleRuleMoveMode({ + key, + baseWidget: expandedWidget, + selectedIndex: ruleSelectedIndex, + setSelectedIndex: setRuleSelectedIndex, + setMoveMode: setRuleMoveMode, + onUpdate: updateExpandedWidget + }); + return; + } + + // 4. Rule color mode + if (ruleEditorMode === 'color') { + // Up/Down navigate rules + if (key.upArrow) { + setRuleSelectedIndex(Math.max(0, ruleSelectedIndex - 1)); + return; + } + if (key.downArrow) { + setRuleSelectedIndex(Math.min(rules.length - 1, ruleSelectedIndex + 1)); + return; + } + + // ESC: if sub-mode active, delegate to handleRuleColorInput (it cancels sub-mode) + // If no sub-mode, switch to property mode + if (key.escape) { + if (ruleColorEditorState.hexInputMode || ruleColorEditorState.ansi256InputMode) { + if (currentRule) { + handleRuleColorInput({ + input, + key, + baseWidget: expandedWidget, + rule: currentRule, + ruleIndex: ruleSelectedIndex, + settings, + colorEditorState: ruleColorEditorState, + setColorEditorState: setRuleColorEditorState, + onUpdate: updateExpandedWidget + }); + } + } else { + setRuleEditorMode('property'); + } + return; + } + + // Tab: switch to property mode, reset sub-modes + if (key.tab) { + if (ruleColorEditorState.hexInputMode || ruleColorEditorState.ansi256InputMode) { + setRuleColorEditorState(prev => ({ + ...prev, + hexInputMode: false, + hexInput: '', + ansi256InputMode: false, + ansi256Input: '' + })); + } + setRuleEditorMode('property'); + return; + } + + // Delegate remaining to handleRuleColorInput + if (currentRule) { + handleRuleColorInput({ + input, + key, + baseWidget: expandedWidget, + rule: currentRule, + ruleIndex: ruleSelectedIndex, + settings, + colorEditorState: ruleColorEditorState, + setColorEditorState: setRuleColorEditorState, + onUpdate: updateExpandedWidget + }); + } + return; + } + + // 5. Rule property mode (the only remaining mode after 'color' above) + // Up/Down navigate rules + if (key.upArrow) { + setRuleSelectedIndex(Math.max(0, ruleSelectedIndex - 1)); + return; + } + if (key.downArrow) { + setRuleSelectedIndex(Math.min(rules.length - 1, ruleSelectedIndex + 1)); + return; + } + + // ESC: collapse rules + if (key.escape) { + setExpandedWidgetId(null); + return; + } + + // Tab: switch to color mode (only if widget supports colors) + if (key.tab) { + const widgetImpl = expandedWidget.type !== 'separator' && expandedWidget.type !== 'flex-separator' + ? getWidget(expandedWidget.type) + : null; + if (widgetImpl?.supportsColors(expandedWidget)) { + setRuleEditorMode('color'); + } + return; + } + + // Enter: start rule move mode + if (key.return) { + setRuleMoveMode(true); + return; + } + + // a: add rule + if (input === 'a') { + addRule({ + baseWidget: expandedWidget, + setSelectedIndex: setRuleSelectedIndex, + onUpdate: updateExpandedWidget + }); + return; + } + + // d: delete rule, auto-collapse if empty + if (input === 'd') { + deleteRule({ + baseWidget: expandedWidget, + selectedIndex: ruleSelectedIndex, + setSelectedIndex: setRuleSelectedIndex, + onUpdate: updateExpandedWidget + }); + // Check if rules are now empty (after delete) + const remainingRules = (expandedWidget.rules ?? []).filter((_, i) => i !== ruleSelectedIndex); + if (remainingRules.length === 0) { + setExpandedWidgetId(null); + } + return; + } + + // Left/Right: open condition editor + if (key.leftArrow || key.rightArrow) { + if (rules.length > 0) { + setRuleConditionEditorIndex(ruleSelectedIndex); + } + return; + } + + // Delegate remaining to handleRulePropertyInput + if (currentRule) { + handleRulePropertyInput({ + input, + key, + baseWidget: expandedWidget, + rule: currentRule, + ruleIndex: ruleSelectedIndex, + onUpdate: updateExpandedWidget, + getCustomKeybindsForWidget, + setCustomEditorWidget + }); + } + + // Unconditional return — prevent fallthrough to widget-level handlers + return; + } + // Color mode input routing if (editorMode === 'color') { const widget = widgets[selectedIndex]; From df5879281dfafb68253657afc950dc5349dc6356 Mon Sep 17 00:00:00 2001 From: Mark O'Keefe Date: Mon, 23 Mar 2026 21:20:54 +1100 Subject: [PATCH 20/42] feat: add accordion rendering for expanded rules in widget list Render rules inline below their parent widget when expanded via 'x' key. Each rule line shows indented selector, styled label with rule colors, condition summary, stop indicator, and applied properties. Parent widget loses selector arrow and (N rules) annotation when expanded. Selector colors match mode: green for property, magenta for color, blue for move. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/tui/components/ItemsEditor.tsx | 152 ++++++++++++++++++++--------- 1 file changed, 104 insertions(+), 48 deletions(-) diff --git a/src/tui/components/ItemsEditor.tsx b/src/tui/components/ItemsEditor.tsx index ecc92829..a111ed31 100644 --- a/src/tui/components/ItemsEditor.tsx +++ b/src/tui/components/ItemsEditor.tsx @@ -19,6 +19,7 @@ import { } from '../../utils/colors'; import { generateGuid } from '../../utils/guid'; import { canDetectTerminalWidth } from '../../utils/terminal'; +import { mergeWidgetWithRuleApply } from '../../utils/widget-properties'; import { filterWidgetCatalog, getWidget, @@ -41,6 +42,10 @@ import { type WidgetPickerAction, type WidgetPickerState } from './items-editor/input-handlers'; +import { + formatAppliedProperties, + formatCondition +} from './rules-editor/formatting'; import { addRule, deleteRule, @@ -984,10 +989,14 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB const { displayText, modifierText } = widgetImpl?.getEditorDisplay(widget) ?? { displayText: getWidgetDisplay(widget) }; const supportsRawValue = widgetImpl?.supportsRawValue() ?? false; const inColorMode = editorMode === 'color'; + const isExpanded = expandedWidgetId === widget.id; // Determine selector color: blue for move, magenta for color, green for items const selectorColor = moveMode ? 'blue' : inColorMode ? 'magenta' : 'green'; + // When rules are expanded, parent widget loses its selector arrow + const showParentSelector = isSelected && !isExpanded; + // Build styled label for color mode let styledLabel: string | undefined; if (inColorMode && !isSep && widgetImpl) { @@ -1005,56 +1014,103 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB ); } + // Build rule rows for expanded widget + const rules = isExpanded ? (widget.rules ?? []) : []; + return ( - - - - {isSelected ? (moveMode ? '◆ ' : '▶ ') : ' '} - + + + + + {showParentSelector ? (moveMode ? '◆ ' : '▶ ') : ' '} + + + {inColorMode && isSep ? ( + + {`${index + 1}. ${displayText || getWidgetDisplay(widget)}`} + + ) : inColorMode && styledLabel ? ( + + {`${index + 1}. `} + {styledLabel} + + ) : ( + + {`${index + 1}. ${displayText || getWidgetDisplay(widget)}`} + + )} + {modifierText && ( + + {' '} + {modifierText} + + )} + {widget.type !== 'separator' && widget.type !== 'flex-separator' && ( + + {' '} + [ + {widget.color ?? 'default'} + ] + + )} + {supportsRawValue && widget.rawValue && (raw value)} + {widget.merge === true && (merged→)} + {widget.merge === 'no-padding' && (merged-no-pad→)} + {!isExpanded && widget.rules && widget.rules.length > 0 && ( + + {' '} + ( + {widget.rules.length} + {' '} + rule + {widget.rules.length === 1 ? '' : 's'} + ) + + )} - {inColorMode && isSep ? ( - - {`${index + 1}. ${displayText || getWidgetDisplay(widget)}`} - - ) : inColorMode && styledLabel ? ( - - {`${index + 1}. `} - {styledLabel} - - ) : ( - - {`${index + 1}. ${displayText || getWidgetDisplay(widget)}`} - - )} - {modifierText && ( - - {' '} - {modifierText} - - )} - {widget.type !== 'separator' && widget.type !== 'flex-separator' && ( - - {' '} - [ - {widget.color ?? 'default'} - ] - - )} - {supportsRawValue && widget.rawValue && (raw value)} - {widget.merge === true && (merged→)} - {widget.merge === 'no-padding' && (merged-no-pad→)} - {widget.rules && widget.rules.length > 0 && ( - - {' '} - ( - {widget.rules.length} - {' '} - rule - {widget.rules.length === 1 ? '' : 's'} - ) - - )} - + {isExpanded && rules.map((rule, ruleIndex) => { + const isRuleSelected = ruleIndex === ruleSelectedIndex; + const condition = formatCondition(rule.when); + const stopIndicator = rule.stop ? ' (stop)' : ''; + const appliedProps = formatAppliedProperties(rule.apply, widget); + + // Get display name for rule line + const ruleDisplayName = widgetImpl + ? (widgetImpl.getEditorDisplay(widget).displayText || getWidgetDisplay(widget)) + : getWidgetDisplay(widget); + + // Get effective colors via mergeWidgetWithRuleApply + const tempWidget = mergeWidgetWithRuleApply(widget, rule.apply); + const colorLevel = getColorLevelString(settings.colorLevel); + const effectiveColor = tempWidget.color ?? widgetImpl?.getDefaultColor() ?? 'white'; + const effectiveBg = tempWidget.backgroundColor; + const effectiveBold = tempWidget.bold ?? false; + const ruleStyledLabel = applyColors(ruleDisplayName, effectiveColor, effectiveBg, effectiveBold, colorLevel); + + // Selector colors: green in property mode, magenta in color mode, blue in move mode + const ruleSelectorColor = ruleMoveMode ? 'blue' : ruleEditorMode === 'color' ? 'magenta' : 'green'; + + return ( + + + {' '} + + + + {isRuleSelected ? (ruleMoveMode ? '◆ ' : '▶ ') : ' '} + + + + {`${ruleIndex + 1}.`} + {ruleMoveMode ? ruleDisplayName : ruleStyledLabel} + + + {` (${condition})${stopIndicator}${appliedProps}`} + + + ); + })} + ); })} {/* Display description for selected widget */} From 897ece2b1d459c9b0372ebf470947b1b76674663 Mon Sep 17 00:00:00 2001 From: Mark O'Keefe Date: Mon, 23 Mar 2026 21:23:57 +1100 Subject: [PATCH 21/42] feat: add rule-level help text, title bar updates, and color info display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - buildHelpText() now checks expandedWidgetId first and returns context-appropriate help for rule move mode, rule color mode, and rule property mode (with widget custom keybinds, raw value, and merge hints) - Title bar shows "Edit Line N — Rules for {widgetDisplayName}" when rules are expanded; mode indicators (MOVE/COLOR) respect whether we are in rule-level or widget-level context - Rule-level color info display shows current foreground/background using mergeWidgetWithRuleApply(baseWidget, rule.apply) as the temp widget - Hex/ANSI256 input prompts rendered at rule level using ruleColorEditorState Co-Authored-By: Claude Sonnet 4.6 --- src/tui/components/ItemsEditor.tsx | 146 ++++++++++++++++++++++++++++- 1 file changed, 143 insertions(+), 3 deletions(-) diff --git a/src/tui/components/ItemsEditor.tsx b/src/tui/components/ItemsEditor.tsx index a111ed31..be18f189 100644 --- a/src/tui/components/ItemsEditor.tsx +++ b/src/tui/components/ItemsEditor.tsx @@ -644,6 +644,55 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB // Build mode-aware help text const buildHelpText = (): string => { + // Rule-level modes take priority + if (expandedWidgetId !== null) { + if (ruleMoveMode) { + return '↑↓ move rule, Enter/ESC exit move mode'; + } + + if (ruleEditorMode === 'color') { + const { hexInputMode, ansi256InputMode, editingBackground } = ruleColorEditorState; + + if (hexInputMode || ansi256InputMode) { + // Sub-modes render their own prompts + return ''; + } + + const colorType = editingBackground ? 'background' : 'foreground'; + const hexAnsiHelp = settings.colorLevel === 3 + ? ', (h)ex' + : settings.colorLevel === 2 + ? ', (a)nsi256' + : ''; + + return `←→ cycle ${colorType}, (f) bg/fg, (b)old${hexAnsiHelp}, (r)eset\nTab: property mode, ESC: property mode`; + } + + // Rule property mode + const expandedWidget = widgets.find(w => w.id === expandedWidgetId); + let ruleText = '↑↓ select, ←→ edit condition, Enter move, (a)dd, (d)elete, (s)top, (h)ide, (c)lear properties, Tab: color mode, ESC: collapse'; + + if (expandedWidget && expandedWidget.type !== 'separator' && expandedWidget.type !== 'flex-separator') { + const expandedWidgetImpl = getWidget(expandedWidget.type); + if (expandedWidgetImpl) { + const widgetCustomKeybinds = getCustomKeybindsForWidget(expandedWidgetImpl, expandedWidget); + if (widgetCustomKeybinds.length > 0) { + ruleText += '\n' + widgetCustomKeybinds.map(kb => kb.label).join(', '); + } + if (expandedWidgetImpl.supportsRawValue()) { + ruleText += ', (r)aw value'; + } + // Check if merge is applicable (widget is not the last) + const expandedWidgetIndex = widgets.findIndex(w => w.id === expandedWidgetId); + if (expandedWidgetIndex !== -1 && expandedWidgetIndex < widgets.length - 1) { + ruleText += ', (m)erge'; + } + } + } + + return ruleText; + } + if (editorMode === 'color') { const { editingBackground, hexInputMode, ansi256InputMode } = colorEditorState; @@ -741,6 +790,14 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB ); } + // Compute expanded widget display name for title bar + const expandedWidget = expandedWidgetId !== null ? widgets.find(w => w.id === expandedWidgetId) : null; + const expandedWidgetDisplayName = expandedWidget + ? (expandedWidget.type !== 'separator' && expandedWidget.type !== 'flex-separator' + ? (getWidget(expandedWidget.type)?.getDisplayName() ?? expandedWidget.type) + : expandedWidget.type) + : null; + return ( @@ -748,10 +805,20 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB Edit Line {' '} {lineNumber} - {' '} + {expandedWidgetDisplayName !== null + ? ` — Rules for ${expandedWidgetDisplayName}` + : ' '} - {moveMode && [MOVE MODE]} - {!moveMode && !widgetPicker && editorMode === 'color' && ( + {expandedWidgetId !== null && ruleMoveMode && [MOVE MODE]} + {expandedWidgetId !== null && !ruleMoveMode && ruleEditorMode === 'color' && ( + + [COLOR MODE + {ruleColorEditorState.editingBackground ? ' - BACKGROUND' : ' - FOREGROUND'} + ] + + )} + {expandedWidgetId === null && moveMode && [MOVE MODE]} + {expandedWidgetId === null && !moveMode && !widgetPicker && editorMode === 'color' && ( [COLOR MODE {colorEditorState.editingBackground ? ' - BACKGROUND' : ' - FOREGROUND'} @@ -906,6 +973,79 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB )} )} + {!widgetPicker && expandedWidgetId !== null && ruleEditorMode === 'color' && ruleColorEditorState.hexInputMode && ( + + Enter 6-digit hex color code (without #): + + # + {ruleColorEditorState.hexInput} + + {ruleColorEditorState.hexInput.length < 6 ? '_'.repeat(6 - ruleColorEditorState.hexInput.length) : ''} + + + + )} + {!widgetPicker && expandedWidgetId !== null && ruleEditorMode === 'color' && ruleColorEditorState.ansi256InputMode && ( + + Enter ANSI 256 color code (0-255): + + {ruleColorEditorState.ansi256Input} + + {ruleColorEditorState.ansi256Input.length === 0 + ? '___' + : ruleColorEditorState.ansi256Input.length === 1 + ? '__' + : ruleColorEditorState.ansi256Input.length === 2 + ? '_' + : ''} + + + + )} + {!widgetPicker && expandedWidgetId !== null && ruleEditorMode === 'color' && !ruleColorEditorState.hexInputMode && !ruleColorEditorState.ansi256InputMode && (() => { + const baseWidget = widgets.find(w => w.id === expandedWidgetId); + if (!baseWidget) { + return null; + } + + const isSep = baseWidget.type === 'separator' || baseWidget.type === 'flex-separator'; + if (isSep) { + return null; + } + + const currentRule = (baseWidget.rules ?? [])[ruleSelectedIndex]; + const tempWidget = currentRule ? mergeWidgetWithRuleApply(baseWidget, currentRule.apply) : baseWidget; + + const { colorIndex, totalColors, displayName } = getCurrentColorInfo( + tempWidget, + ruleColorEditorState.editingBackground + ); + + const colorType = ruleColorEditorState.editingBackground ? 'background' : 'foreground'; + const colorNumber = colorIndex === -1 ? 'custom' : `${colorIndex}/${totalColors}`; + + const level = getColorLevelString(settings.colorLevel); + const styledColor = ruleColorEditorState.editingBackground + ? applyColors(` ${displayName} `, undefined, tempWidget.backgroundColor, false, level) + : applyColors(displayName, tempWidget.color, undefined, false, level); + + return ( + + + Current + {' '} + {colorType} + {' '} + ( + {colorNumber} + ): + {' '} + {styledColor} + {tempWidget.bold && [BOLD]} + + + ); + })()} {!widgetPicker && editorMode === 'color' && colorEditorState.hexInputMode && ( Enter 6-digit hex color code (without #): From 867501c39ca0d0ed807fe974a3b23bb141ef58b3 Mon Sep 17 00:00:00 2001 From: Mark O'Keefe Date: Mon, 23 Mar 2026 21:34:42 +1100 Subject: [PATCH 22/42] feat: wire ConditionEditor overlay at rule property level in ItemsEditor Add ConditionEditor import and render it as an early-return overlay when ruleConditionEditorIndex is set and an expanded widget exists. The onSave callback updates the rule's when condition in the full widgets array and calls onUpdate, then clears the editor index. Co-Authored-By: Claude Sonnet 4.6 --- src/tui/components/ItemsEditor.tsx | 32 ++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/tui/components/ItemsEditor.tsx b/src/tui/components/ItemsEditor.tsx index be18f189..21528b3c 100644 --- a/src/tui/components/ItemsEditor.tsx +++ b/src/tui/components/ItemsEditor.tsx @@ -27,6 +27,7 @@ import { getWidgetCatalogCategories } from '../../utils/widgets'; +import { ConditionEditor } from './ConditionEditor'; import { ConfirmDialog } from './ConfirmDialog'; import { getCurrentColorInfo, @@ -790,6 +791,37 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB ); } + // If condition editor is active, render it instead of the normal UI + if (ruleConditionEditorIndex !== null && expandedWidgetId !== null) { + const condEditorWidget = widgets.find(w => w.id === expandedWidgetId); + if (condEditorWidget) { + const condEditorRules = condEditorWidget.rules ?? []; + const condEditorRule = condEditorRules[ruleConditionEditorIndex]; + if (condEditorRule) { + return ( + { + const newRules = [...condEditorRules]; + newRules[ruleConditionEditorIndex] = { + ...condEditorRule, + when: newCondition + }; + const newWidgets = widgets.map(w => w.id === expandedWidgetId ? { ...w, rules: newRules } : w); + onUpdate(newWidgets); + setRuleConditionEditorIndex(null); + }} + onCancel={() => { setRuleConditionEditorIndex(null); }} + /> + ); + } + // Rule index out of bounds — reset + setRuleConditionEditorIndex(null); + } + } + // Compute expanded widget display name for title bar const expandedWidget = expandedWidgetId !== null ? widgets.find(w => w.id === expandedWidgetId) : null; const expandedWidgetDisplayName = expandedWidget From eaec1beb17aa337fecbff7c87ea27a57c38383b4 Mon Sep 17 00:00:00 2001 From: Mark O'Keefe Date: Mon, 23 Mar 2026 21:36:26 +1100 Subject: [PATCH 23/42] refactor: delete RulesEditor.tsx, update stale comments Replaced by accordion rules editor inline in ItemsEditor. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../1885621-1774257922/.server-info | 1 + .../1885621-1774257922/accordion-models.html | 62 ++ .../1885621-1774257922/full-design-v2.html | 96 +++ .../1885621-1774257922/full-design.html | 142 ++++ .../1885621-1774257922/waiting.html | 3 + src/tui/components/RulesEditor.tsx | 697 ------------------ .../components/color-editor/input-handlers.ts | 2 +- .../components/items-editor/input-handlers.ts | 2 +- src/utils/widget-properties.ts | 4 +- 9 files changed, 308 insertions(+), 701 deletions(-) create mode 100644 .superpowers/brainstorm/1885621-1774257922/.server-info create mode 100644 .superpowers/brainstorm/1885621-1774257922/accordion-models.html create mode 100644 .superpowers/brainstorm/1885621-1774257922/full-design-v2.html create mode 100644 .superpowers/brainstorm/1885621-1774257922/full-design.html create mode 100644 .superpowers/brainstorm/1885621-1774257922/waiting.html delete mode 100644 src/tui/components/RulesEditor.tsx diff --git a/.superpowers/brainstorm/1885621-1774257922/.server-info b/.superpowers/brainstorm/1885621-1774257922/.server-info new file mode 100644 index 00000000..bc393ae7 --- /dev/null +++ b/.superpowers/brainstorm/1885621-1774257922/.server-info @@ -0,0 +1 @@ +{"type":"server-started","port":63636,"host":"127.0.0.1","url_host":"localhost","url":"http://localhost:63636","screen_dir":"/home/mark/GitHub/ccstatusline-wtree/feat/unified-editing/.superpowers/brainstorm/1885621-1774257922"} diff --git a/.superpowers/brainstorm/1885621-1774257922/accordion-models.html b/.superpowers/brainstorm/1885621-1774257922/accordion-models.html new file mode 100644 index 00000000..ca9b2a2d --- /dev/null +++ b/.superpowers/brainstorm/1885621-1774257922/accordion-models.html @@ -0,0 +1,62 @@ +

Accordion Rules: How should expanded rules look?

+

When you press a key on a widget, its rules expand inline below it. But how much detail should each rule show?

+ +
+
+
A
+
+

Compact: Condition + Color Preview Only

+
+
ItemsEditor - Compact Rules
+
+
Edit Line 1
+
1. Model [cyan]
+
▸ when >50 ■ red
+
▸ when >80 ■ red, bold (hidden)
+
2. GitBranch [green]
+
3. TokensTotal [white] (2 rules)
+
+
+

Each rule is one short line. Condition summary + color swatch + key overrides. Quick to scan. Less info but less clutter.

+
+
+ +
+
B
+
+

Detailed: Full Condition + All Properties

+
+
ItemsEditor - Detailed Rules
+
+
Edit Line 1
+
1. Model [cyan]
+
▸ 1. Model (when self greaterThan 50) (raw value)
+
▸ 2. Model (when self greaterThan 80) (stop) (hidden)
+
2. GitBranch [green]
+
3. TokensTotal [white] (2 rules)
+
+
+

Mirrors current RulesEditor display: styled widget name + full condition text + all property overrides. More info but takes more space.

+
+
+ +
+
C
+
+

Hybrid: Compact by default, expand selected rule

+
+
ItemsEditor - Hybrid Rules
+
+
Edit Line 1
+
1. Model [cyan]
+
▸ when >50 ■ red
+
Model (when self greaterThan 80) (stop) (hidden)
+
↑↓ select, ←→ edit condition, Tab: color mode, (s)top, (h)ide
+
2. GitBranch [green]
+
3. TokensTotal [white] (2 rules)
+
+
+

Unselected rules show compact view. The selected rule shows full detail + inline help text. Best of both: scannable list, detailed editing.

+
+
+
diff --git a/.superpowers/brainstorm/1885621-1774257922/full-design-v2.html b/.superpowers/brainstorm/1885621-1774257922/full-design-v2.html new file mode 100644 index 00000000..1d53992d --- /dev/null +++ b/.superpowers/brainstorm/1885621-1774257922/full-design-v2.html @@ -0,0 +1,96 @@ +

Accordion Rules Editor: Full Design (v2)

+

Fixed: Selection arrow stays in a fixed column. Indented rules have their own arrow column.

+ +
+

1. Normal Widget View (no rules expanded)

+
+
Edit Line 1
+
+
↑↓ select, ←→ type picker, (a)dd, (d)elete, (x) rules, Tab: color mode, ESC: back
+
1. Model [cyan] (2 rules)
+
2. GitBranch [green]
+
3. TokensTotal [white] (1 rule)
+
4. Separator |
+
5. SessionCost [yellow]
+
+
+

↑↓ navigates widgets. Arrow column is fixed at left edge.

+
+ +
+

2. Press x — Rules expand, focus shifts into rules

+
+
Edit Line 1 — Rules for Model
+
+
↑↓ select, ←→ edit condition, (a)dd, (d)elete, (s)top, (h)ide, Tab: color mode, ESC: collapse
+
1. Model [cyan]
+
1. Model (when self > 50) (raw value)
+
2. Model (when self > 80) (stop) (hidden)
+
2. GitBranch [green]
+
3. TokensTotal [white] (1 rule)
+
+
+

Rules are permanently indented under the parent widget. The rule arrow column is offset from the widget arrow column. The parent widget loses its arrow (no longer selected). ↑↓ navigates rules only.

+
+ +
+

3. Navigating to rule 2

+
+
Edit Line 1 — Rules for Model
+
+
↑↓ select, ←→ edit condition, (a)dd, (d)elete, (s)top, (h)ide, Tab: color mode, ESC: collapse
+
1. Model [cyan]
+
1. Model (when self > 50) (raw value)
+
2. Model (when self > 80) (stop) (hidden)
+
2. GitBranch [green]
+
3. TokensTotal [white] (1 rule)
+
+
+

Arrow moves down to rule 2. Nothing else shifts. The indentation is fixed — only the arrow position changes.

+
+ +
+

4. Tab to color mode (on rule 2)

+
+
Edit Line 1 — Rules for Model [COLOR MODE - FOREGROUND]
+
+
←→ cycle foreground, (f) bg/fg, (b)old, (r)eset, Tab: property mode, ESC: property mode
+
Current foreground (3/16): Red
+
1. Model [cyan]
+
1. Model (when self > 50) (raw value)
+
2. Model (when self > 80) (stop) (hidden)
+
2. GitBranch [green]
+
+
+

Magenta arrow for color mode. Color info shown. Everything else stays put.

+
+ +
+

5. ESC Layering

+
+
Navigation Stack
+
+
Rule color mode    → ESC →   Rule property mode
+
Rule property mode  → ESC →   Collapse rules (widget level)
+
Widget color mode   → ESC →   Widget items mode
+
Widget items mode   → ESC →   Line Selector
+
+
+
+ +
+
+
+
+

Design looks correct

+

Ready to write up as a spec

+
+
+
+
+
+

Needs changes

+

I'll describe what to adjust in the terminal

+
+
+
diff --git a/.superpowers/brainstorm/1885621-1774257922/full-design.html b/.superpowers/brainstorm/1885621-1774257922/full-design.html new file mode 100644 index 00000000..fd4ecf4b --- /dev/null +++ b/.superpowers/brainstorm/1885621-1774257922/full-design.html @@ -0,0 +1,142 @@ +

Accordion Rules Editor: Full Design

+

Review the complete interaction model. Does this capture everything correctly?

+ +
+

1. Normal Widget View (no rules expanded)

+
+
Edit Line 1 — Items Mode
+
+
↑↓ select, ←→ open type picker, (a)dd, (d)elete, (x) rules, Tab: color mode, ESC: back
+
1. Model [cyan] (2 rules)
+
2. GitBranch [green]
+
3. TokensTotal [white] (1 rule)
+
4. Separator |
+
5. SessionCost [yellow]
+
+
+

Standard widget list. (x) rules hint visible. Rule count shown as annotation.

+
+ +
+

2. Rules Expanded (press x — focus shifts to rules)

+
+
Edit Line 1 — Rules for Model [PROPERTY MODE]
+
+
↑↓ select, ←→ edit condition, Enter move, (a)dd, (d)elete, (s)top, (h)ide, Tab: color mode, ESC: collapse
+
1. Model [cyan] (2 rules)
+
1. Model (when self > 50) (raw value)
+
2. Model (when self > 80) (stop) (hidden)
+
2. GitBranch [green]
+
3. TokensTotal [white] (1 rule)
+
+
+

Rules expand inline below the widget. The widget's selector dims () to show it's the parent, not the focus. The first rule gets the active selector. ↑↓ navigates between rules only. Help text updates to show rule-level keybinds.

+
+ +
+

3. Tab to Color Mode (on a rule)

+
+
Edit Line 1 — Rules for Model [COLOR MODE - FOREGROUND]
+
+
←→ cycle foreground, (f) bg/fg, (b)old, (r)eset, Tab: property mode, ESC: property mode
+
Current foreground (3/16): Red
+
1. Model [cyan] (2 rules)
+
1. Model (when self > 50) (raw value)
+
2. Model (when self > 80) (stop) (hidden)
+
2. GitBranch [green]
+
+
+

Tab switches to color mode for the selected rule. Magenta selector. Color info displayed. ←→ cycles colors for the rule's override. Same UX as widget-level color editing.

+
+ +
+

4. ESC Behavior (layered peeling)

+
+
Navigation Stack
+
+
Rule color mode   ⟶ ESC ⟶   Rule property mode
+
Rule property mode   ⟶ ESC ⟶   Collapse rules (widget level)
+
Widget color mode   ⟶ ESC ⟶   Widget items mode
+
Widget items mode   ⟶ ESC ⟶   Line Selector
+
+
+

ESC always peels back one layer. Never skips a level. Predictable and safe.

+
+ +
+

5. Keybind Parity Table

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
KeyWidget LevelRule Level
↑↓Navigate widgetsNavigate rules
TabItems ↔ Color modeProperty ↔ Color mode
←→Type picker (items) / Cycle colorCondition editor (prop) / Cycle color
aAdd widgetAdd rule
dDelete widgetDelete rule
EnterMove mode (reorder)Move mode (reorder)
xExpand rules (drill in)N/A
sN/AToggle stop flag
hHex input (color mode)Hide toggle (prop) / Hex input (color)
ESCBack to line selectorCollapse rules (back to widget)
+
+ +
+
+
+
+

Design looks correct

+

Ready to write up as a spec

+
+
+
+
+
+

Needs changes

+

I'll describe what to adjust in the terminal

+
+
+
diff --git a/.superpowers/brainstorm/1885621-1774257922/waiting.html b/.superpowers/brainstorm/1885621-1774257922/waiting.html new file mode 100644 index 00000000..ef076525 --- /dev/null +++ b/.superpowers/brainstorm/1885621-1774257922/waiting.html @@ -0,0 +1,3 @@ +
+

Continuing in terminal...

+
diff --git a/src/tui/components/RulesEditor.tsx b/src/tui/components/RulesEditor.tsx deleted file mode 100644 index 47931e2b..00000000 --- a/src/tui/components/RulesEditor.tsx +++ /dev/null @@ -1,697 +0,0 @@ -import { - Box, - Text, - useInput -} from 'ink'; -import React, { useState } from 'react'; - -import { getColorLevelString } from '../../types/ColorLevel'; -import { - DISPLAY_OPERATOR_LABELS, - OPERATOR_LABELS, - getConditionNot, - getConditionOperator, - getConditionValue, - getConditionWidget, - getDisplayOperator, - isBooleanOperator, - isSetOperator, - isStringOperator -} from '../../types/Condition'; -import type { Settings } from '../../types/Settings'; -import type { WidgetItem } from '../../types/Widget'; -import { applyColors } from '../../utils/colors'; -import { - extractWidgetOverrides, - mergeWidgetWithRuleApply -} from '../../utils/widget-properties'; -import { getWidget } from '../../utils/widgets'; - -import { ConditionEditor } from './ConditionEditor'; -import { - getCurrentColorInfo, - handleColorInput, - type ColorEditorState -} from './color-editor/input-handlers'; -import { handleWidgetPropertyInput } from './items-editor/input-handlers'; - -export interface RulesEditorProps { - widget: WidgetItem; - settings: Settings; - onUpdate: (updatedWidget: WidgetItem) => void; - onBack: () => void; -} - -type RulesEditorMode = 'color' | 'property'; - -export const RulesEditor: React.FC = ({ widget, settings, onUpdate, onBack }) => { - const [selectedIndex, setSelectedIndex] = useState(0); - const [moveMode, setMoveMode] = useState(false); - const [conditionEditorIndex, setConditionEditorIndex] = useState(null); - const [editorMode, setEditorMode] = useState('property'); - const [colorEditorState, setColorEditorState] = useState({ - editingBackground: false, - hexInputMode: false, - hexInput: '', - ansi256InputMode: false, - ansi256Input: '' - }); - const [customEditorWidget, setCustomEditorWidget] = useState<{ widget: WidgetItem; impl: ReturnType; action?: string } | null>(null); - const rules = widget.rules ?? []; - - // Add new rule with placeholder values - const addRule = () => { - const newRule = { - when: { greaterThan: 50 }, // Placeholder - Phase 4 will make this editable - apply: {}, // Empty overrides - Phase 7 will make this editable - stop: false - }; - - const newRules = [...rules, newRule]; - const updatedWidget = { ...widget, rules: newRules }; - onUpdate(updatedWidget); - setSelectedIndex(newRules.length - 1); // Select newly added rule - }; - - // Delete selected rule - const deleteRule = () => { - if (rules.length === 0) { - return; - } - - const newRules = rules.filter((_, i) => i !== selectedIndex); - const updatedWidget = { ...widget, rules: newRules }; - onUpdate(updatedWidget); - - // Adjust selection after delete (same pattern as ItemsEditor) - if (selectedIndex >= newRules.length && selectedIndex > 0) { - setSelectedIndex(selectedIndex - 1); - } - }; - - // Handle custom editor completion - const handleEditorComplete = (updatedWidget: WidgetItem) => { - const rule = rules[selectedIndex]; - if (!rule) { - setCustomEditorWidget(null); - return; - } - - // Extract what changed compared to base widget - const newApply = extractWidgetOverrides(updatedWidget, widget, rule.apply); - const newRules = [...rules]; - newRules[selectedIndex] = { - ...rule, - apply: newApply - }; - onUpdate({ ...widget, rules: newRules }); - setCustomEditorWidget(null); - }; - - // Handle custom editor cancellation - const handleEditorCancel = () => { - setCustomEditorWidget(null); - }; - - // Handle color mode input using shared handler - const handleColorModeInput = (input: string, key: { leftArrow?: boolean; rightArrow?: boolean; escape?: boolean; return?: boolean; backspace?: boolean; delete?: boolean; upArrow?: boolean; downArrow?: boolean }) => { - const rule = rules[selectedIndex]; - if (!rule) { - return; - } - - // Create temp widget by merging base + apply - const tempWidget = mergeWidgetWithRuleApply(widget, rule.apply); - - // Use shared color input handler - handleColorInput({ - input, - key, - widget: tempWidget, - settings, - state: colorEditorState, - setState: setColorEditorState, - onUpdate: (updatedWidget) => { - // Extract what changed compared to base widget - const newApply = extractWidgetOverrides(updatedWidget, widget, rule.apply); - const newRules = [...rules]; - newRules[selectedIndex] = { - ...rule, - apply: newApply - }; - onUpdate({ ...widget, rules: newRules }); - }, - onReset: () => { - // Reset colors to base widget (remove color/backgroundColor/bold from apply) - const resetWidget = { - ...tempWidget, - color: widget.color, - backgroundColor: widget.backgroundColor, - bold: widget.bold - }; - const newApply = extractWidgetOverrides(resetWidget, widget, rule.apply); - const newRules = [...rules]; - newRules[selectedIndex] = { - ...rule, - apply: newApply - }; - onUpdate({ ...widget, rules: newRules }); - } - }); - }; - - // Handle property mode input - const handlePropertyModeInput = (input: string, key: { ctrl?: boolean; meta?: boolean }) => { - const rule = rules[selectedIndex]; - if (!rule) { - return; - } - - // Handle rule-specific properties - if (input === 's') { - // Toggle stop flag - const newRules = [...rules]; - newRules[selectedIndex] = { - ...rule, - stop: !rule.stop - }; - onUpdate({ ...widget, rules: newRules }); - return; - } - - if (input === 'h') { - // Toggle hide flag - const tempWidget = mergeWidgetWithRuleApply(widget, rule.apply); - const updatedWidget = { ...tempWidget, hide: !tempWidget.hide }; - - // Extract diffs and update - const newApply = extractWidgetOverrides(updatedWidget, widget, rule.apply); - const newRules = [...rules]; - newRules[selectedIndex] = { - ...rule, - apply: newApply - }; - onUpdate({ ...widget, rules: newRules }); - return; - } - - if (input === 'c') { - // Clear only property overrides (NOT color/bold/hide - those are managed separately) - const newRules = [...rules]; - const { color, backgroundColor, bold, hide, ...restApply } = rule.apply; - - // Preserve color/bold/hide if they exist - const newApply: Record = {}; - if (color !== undefined) { - newApply.color = color; - } - if (backgroundColor !== undefined) { - newApply.backgroundColor = backgroundColor; - } - if (bold !== undefined) { - newApply.bold = bold; - } - if (hide !== undefined) { - newApply.hide = hide; - } - - void restApply; // All other properties are cleared - - newRules[selectedIndex] = { - ...rule, - apply: newApply - }; - onUpdate({ ...widget, rules: newRules }); - return; - } - - // Handle widget property toggles ('r', 'm', custom keybinds) using shared logic - // Create temp widget by merging base widget with rule.apply - const tempWidget = mergeWidgetWithRuleApply(widget, rule.apply); - - handleWidgetPropertyInput({ - input, - key, - widget: tempWidget, - onUpdate: (updatedWidget) => { - // Extract what changed compared to base widget - const newApply = extractWidgetOverrides(updatedWidget, widget, rule.apply); - - const newRules = [...rules]; - newRules[selectedIndex] = { - ...rule, - apply: newApply - }; - onUpdate({ ...widget, rules: newRules }); - }, - getCustomKeybindsForWidget: (widgetImpl, w) => { - return widgetImpl.getCustomKeybinds?.(w) ?? []; - }, - setCustomEditorWidget - }); - }; - - // Handle move mode input - const handleMoveMode = (key: { upArrow?: boolean; downArrow?: boolean; return?: boolean; escape?: boolean }) => { - if (key.upArrow && selectedIndex > 0) { - // Swap with rule above - const newRules = [...rules]; - const currentRule = newRules[selectedIndex]; - const previousRule = newRules[selectedIndex - 1]; - if (!currentRule || !previousRule) { - return; - } - newRules[selectedIndex] = previousRule; - newRules[selectedIndex - 1] = currentRule; - - onUpdate({ ...widget, rules: newRules }); - setSelectedIndex(selectedIndex - 1); - } else if (key.downArrow && selectedIndex < rules.length - 1) { - // Swap with rule below - const newRules = [...rules]; - const currentRule = newRules[selectedIndex]; - const nextRule = newRules[selectedIndex + 1]; - if (!currentRule || !nextRule) { - return; - } - newRules[selectedIndex] = nextRule; - newRules[selectedIndex + 1] = currentRule; - - onUpdate({ ...widget, rules: newRules }); - setSelectedIndex(selectedIndex + 1); - } else if (key.escape || key.return) { - setMoveMode(false); - } - }; - - useInput((input, key) => { - if (customEditorWidget) { - return; // Let custom editor handle input - } - - if (moveMode) { - handleMoveMode(key); - return; - } - - if (conditionEditorIndex !== null) { - return; // Let ConditionEditor handle input - } - - if (key.escape) { - onBack(); - } else if (key.upArrow && rules.length > 0) { - setSelectedIndex(Math.max(0, selectedIndex - 1)); - } else if (key.downArrow && rules.length > 0) { - setSelectedIndex(Math.min(rules.length - 1, selectedIndex + 1)); - } else if (key.return && rules.length > 0) { - setMoveMode(true); // Enter move mode - } else if (key.tab && rules.length > 0) { - // Toggle between color and property modes - setEditorMode(prev => prev === 'color' ? 'property' : 'color'); - } else if (input === 'a') { - addRule(); - } else if (input === 'd' && rules.length > 0) { - deleteRule(); // Immediate delete, no confirmation (matches ItemsEditor) - } else if (rules.length > 0) { - // Handle mode-specific input - if (editorMode === 'color') { - // Color mode: inline editing - handleColorModeInput(input, key); - } else { - // Property mode: check for condition editor trigger first - if (key.leftArrow || key.rightArrow) { - // Open condition editor (same UX as ItemsEditor widget type picker) - setConditionEditorIndex(selectedIndex); - } else { - handlePropertyModeInput(input, key); - } - } - } - }); - - // Get widget display name - const getWidgetDisplayName = () => { - const widgetImpl = getWidget(widget.type); - return widgetImpl ? widgetImpl.getDisplayName() : widget.type; - }; - - // Format condition summary - const formatCondition = (when: Record): string => { - const widgetRef = getConditionWidget(when); - const operator = getConditionOperator(when); - const value = getConditionValue(when); - const notFlag = getConditionNot(when); - - // Get widget display name - let widgetName = 'self'; - if (widgetRef !== 'self') { - const widgetImpl = getWidget(widgetRef); - widgetName = widgetImpl ? widgetImpl.getDisplayName() : widgetRef; - } - - // Check if this matches a display operator pattern - const displayOp = getDisplayOperator(when); - if (displayOp) { - const displayLabel = DISPLAY_OPERATOR_LABELS[displayOp]; - - // Format based on display operator type - if (displayOp === 'notEquals') { - if (typeof value === 'string') { - return `when ${widgetName} ${displayLabel} "${value}"`; - } - return `when ${widgetName} ${displayLabel} ${value}`; - } - if (displayOp === 'notContains' || displayOp === 'notStartsWith' || displayOp === 'notEndsWith') { - return `when ${widgetName} ${displayLabel} "${value}"`; - } - return `when ${widgetName} ${displayLabel}`; - } - - // Fall back to showing base operator with NOT prefix if needed - const notPrefix = notFlag ? 'NOT ' : ''; - - if (operator && value !== null) { - const opLabel = OPERATOR_LABELS[operator]; - - // Format based on operator type - if (isStringOperator(operator)) { - return `when ${notPrefix}${widgetName} ${opLabel} "${value}"`; - } - - if (isBooleanOperator(operator)) { - return `when ${notPrefix}${widgetName} ${opLabel}`; - } - - if (isSetOperator(operator) && Array.isArray(value)) { - const valueList = value.map(v => JSON.stringify(v)).join(', '); - return `when ${notPrefix}${widgetName} ${opLabel} [${valueList}]`; - } - - // Numeric or equals - return `when ${notPrefix}${widgetName} ${opLabel}${value}`; - } - - return `when ${JSON.stringify(when)}`; - }; - - // Format applied properties as labels (using widget's own display logic) - const formatAppliedProperties = (apply: Record): string => { - // Create a temp widget by merging base widget with rule.apply - // Use shared merge function to handle metadata deep merge correctly - const tempWidget = mergeWidgetWithRuleApply(widget, apply); - - // Let the widget format its own modifiers (hide, remaining, etc.) - const widgetImpl = getWidget(widget.type); - const { modifierText } = widgetImpl?.getEditorDisplay(tempWidget) ?? { modifierText: undefined }; - - // Build labels for base properties (rawValue, merge) - const baseLabels: string[] = []; - - if (tempWidget.rawValue) { - baseLabels.push('raw value'); - } - - if (tempWidget.merge === true) { - baseLabels.push('merged→'); - } else if (tempWidget.merge === 'no-padding') { - baseLabels.push('merged-no-pad→'); - } - - if (tempWidget.hide) { - baseLabels.push('hidden'); - } - - if (tempWidget.character !== undefined) { - baseLabels.push(`character: ${tempWidget.character}`); - } - - // Combine widget-specific modifiers and base property labels - const parts: string[] = []; - if (modifierText) { - parts.push(modifierText); - } - if (baseLabels.length > 0) { - parts.push(...baseLabels.map(l => `(${l})`)); - } - - return parts.length > 0 ? ` ${parts.join(' ')}` : ''; - }; - - // Build help text based on mode - const buildHelpText = (): string => { - if (moveMode) { - return '↑↓ move rule, Enter/ESC exit move mode'; - } - - if (rules.length === 0) { - return '(a)dd rule, ESC back'; - } - - const baseHelp = '↑↓ select, Enter move mode, (a)dd, (d)elete'; - - if (editorMode === 'color') { - const { editingBackground, hexInputMode, ansi256InputMode } = colorEditorState; - - if (hexInputMode) { - return 'Type 6-digit hex code (without #), Enter to apply, ESC to cancel'; - } - - if (ansi256InputMode) { - return 'Type ANSI 256 color code (0-255), Enter to apply, ESC to cancel'; - } - - const colorType = editingBackground ? 'background' : 'foreground'; - const hexAnsiHelp = settings.colorLevel === 3 - ? ', (h)ex' - : settings.colorLevel === 2 - ? ', (a)nsi256' - : ''; - - return `${baseHelp}, ←→ cycle ${colorType}\n(f) bg/fg, (b)old${hexAnsiHelp}, (r)eset\nTab: property mode, ESC back`; - } else { - // Property mode - include widget custom keybinds and base properties - const widgetImpl = getWidget(widget.type); - const customKeybinds = widgetImpl?.getCustomKeybinds?.(widget) ?? []; - const keybindHelp = customKeybinds - .map(kb => kb.label) - .join(', '); - - // Build base property keybinds - const basePropertyKeybinds: string[] = []; - if (widgetImpl?.supportsRawValue()) { - basePropertyKeybinds.push('(r)aw value'); - } - basePropertyKeybinds.push('(m)erge'); - basePropertyKeybinds.push('(h)ide'); - basePropertyKeybinds.push('(s)top'); - basePropertyKeybinds.push('(c)lear properties'); - - const propertyHelp = keybindHelp ? `, ${keybindHelp}` : ''; - const basePropsHelp = basePropertyKeybinds.join(', '); - return `${baseHelp}, ←→ edit condition${propertyHelp}, ${basePropsHelp}\nTab: color mode, ESC back`; - } - }; - - const helpText = buildHelpText(); - - // Show custom widget editor - if (customEditorWidget?.impl?.renderEditor) { - return customEditorWidget.impl.renderEditor({ - widget: customEditorWidget.widget, - onComplete: handleEditorComplete, - onCancel: handleEditorCancel, - action: customEditorWidget.action - }); - } - - // Show condition editor - if (conditionEditorIndex !== null) { - const rule = rules[conditionEditorIndex]; - if (!rule) { - setConditionEditorIndex(null); - return null; - } - - return ( - { - const newRules = [...rules]; - newRules[conditionEditorIndex] = { - ...rule, - when: newCondition - }; - onUpdate({ ...widget, rules: newRules }); - setConditionEditorIndex(null); - }} - onCancel={() => { setConditionEditorIndex(null); }} - /> - ); - } - - return ( - - - - Rules for - {widget.type} - - {moveMode && [MOVE MODE]} - {!moveMode && editorMode === 'color' && ( - - {' '} - [COLOR MODE - {colorEditorState.editingBackground ? ' - BACKGROUND' : ' - FOREGROUND'} - ] - - )} - {!moveMode && editorMode === 'property' && [PROPERTY MODE]} - - - {rules.length === 0 ? ( - No rules defined - ) : ( - <> - - {helpText} - - - {editorMode === 'color' && colorEditorState.hexInputMode && ( - - Enter 6-digit hex color code (without #): - - # - {colorEditorState.hexInput} - - {colorEditorState.hexInput.length < 6 ? '_'.repeat(6 - colorEditorState.hexInput.length) : ''} - - - - )} - - {editorMode === 'color' && colorEditorState.ansi256InputMode && ( - - Enter ANSI 256 color code (0-255): - - {colorEditorState.ansi256Input} - - {colorEditorState.ansi256Input.length === 0 - ? '___' - : colorEditorState.ansi256Input.length === 1 - ? '__' - : colorEditorState.ansi256Input.length === 2 - ? '_' - : ''} - - - - )} - - {editorMode === 'color' && !colorEditorState.hexInputMode && !colorEditorState.ansi256InputMode && (() => { - const rule = rules[selectedIndex]; - if (!rule) { - return null; - } - - // Create temp widget by merging base + apply - const tempWidget = mergeWidgetWithRuleApply(widget, rule.apply); - const { colorIndex, totalColors, displayName } = getCurrentColorInfo( - tempWidget, - colorEditorState.editingBackground - ); - - const colorType = colorEditorState.editingBackground ? 'background' : 'foreground'; - const colorNumber = colorIndex === -1 ? 'custom' : `${colorIndex}/${totalColors}`; - - // Apply color to display name - const level = getColorLevelString(settings.colorLevel); - const styledColor = colorEditorState.editingBackground - ? applyColors(` ${displayName} `, undefined, tempWidget.backgroundColor, false, level) - : applyColors(displayName, tempWidget.color, undefined, false, level); - - return ( - - - Current - {' '} - {colorType} - {' '} - ( - {colorNumber} - ): - {' '} - {styledColor} - {tempWidget.bold && [BOLD]} - - - ); - })()} - - {rules.map((rule, index) => { - const isSelected = index === selectedIndex; - const condition = formatCondition(rule.when); - const stopIndicator = rule.stop ? ' (stop)' : ''; - const appliedProps = formatAppliedProperties(rule.apply); - - // Get widget display name - const displayName = getWidgetDisplayName(); - - // Get widget's actual configured color/bold as base - const widgetImpl = getWidget(widget.type); - const baseColor = widget.color ?? widgetImpl?.getDefaultColor() ?? 'white'; - const baseBackgroundColor = widget.backgroundColor; - const baseBold = widget.bold ?? false; - - // Apply rule overrides (or use widget's configured properties) - const color = rule.apply.color ? String(rule.apply.color) : baseColor; - const backgroundColor = rule.apply.backgroundColor ? String(rule.apply.backgroundColor) : baseBackgroundColor; - const bold = rule.apply.bold !== undefined ? Boolean(rule.apply.bold) : baseBold; - - // Apply colors - const colorLevel = getColorLevelString(settings.colorLevel); - const styledLabel = applyColors(displayName, color, backgroundColor, bold, colorLevel); - - // Selection color: blue in move mode, green otherwise (same as ItemsEditor) - const selectionColor = isSelected ? (moveMode ? 'blue' : 'green') : undefined; - - return ( - - - - {isSelected ? (moveMode ? '◆ ' : '▶ ') : ' '} - - - {/* In move mode, override styling with selection color */} - {/* In normal mode, show rule's styled label */} - - {index + 1} - . - {moveMode ? displayName : styledLabel} - - - {(() => { - const fullText = ` (${condition})${stopIndicator}${appliedProps}`; - return fullText; - })()} - - - ); - })} - - )} - - - - {rules.length === 0 ? ( - {helpText} - ) : ( - `${rules.length} rule${rules.length === 1 ? '' : 's'}` - )} - - - - ); -}; \ No newline at end of file diff --git a/src/tui/components/color-editor/input-handlers.ts b/src/tui/components/color-editor/input-handlers.ts index 600bc4c5..72e8114b 100644 --- a/src/tui/components/color-editor/input-handlers.ts +++ b/src/tui/components/color-editor/input-handlers.ts @@ -53,7 +53,7 @@ function cycleColor( } /** - * Shared color input handler for ItemsEditor and RulesEditor + * Shared color input handler for ItemsEditor and rules-editor input handlers * Returns true if input was handled, false otherwise */ export function handleColorInput({ diff --git a/src/tui/components/items-editor/input-handlers.ts b/src/tui/components/items-editor/input-handlers.ts index c066ce03..4af5608d 100644 --- a/src/tui/components/items-editor/input-handlers.ts +++ b/src/tui/components/items-editor/input-handlers.ts @@ -320,7 +320,7 @@ export function handleMoveInputMode({ /** * Handle widget property editing (raw value, merge, custom keybinds) - * Shared between ItemsEditor and RulesEditor + * Shared between ItemsEditor and rules-editor input handlers */ export interface HandleWidgetPropertyInputArgs { input: string; diff --git a/src/utils/widget-properties.ts b/src/utils/widget-properties.ts index d0bae4c0..1e2d98fb 100644 --- a/src/utils/widget-properties.ts +++ b/src/utils/widget-properties.ts @@ -2,7 +2,7 @@ * Shared widget property mutation utilities * * These functions handle the cycling/toggling of widget properties - * and are used by both ItemsEditor and RulesEditor to ensure consistent behavior. + * and are used by ItemsEditor to ensure consistent behavior. */ import type { WidgetItem } from '../types/Widget'; @@ -105,7 +105,7 @@ export function toggleWidgetRawValue(widget: WidgetItem): WidgetItem { /** * Extract property overrides for rule.apply by comparing updated widget to base widget * - * Used by RulesEditor to determine what should be in rule.apply after widget modifications. + * Used by the accordion rules editor to determine what should be in rule.apply after widget modifications. * Only properties that differ from the base widget are included. * Properties that match the base are removed from the existing apply object. * From ed520290621e95d31ad66adb881b553037a80f37 Mon Sep 17 00:00:00 2001 From: Mark O'Keefe Date: Mon, 23 Mar 2026 21:42:42 +1100 Subject: [PATCH 24/42] =?UTF-8?q?feat:=20wrap=20=E2=86=91=E2=86=93=20navig?= =?UTF-8?q?ation=20at=20both=20widget=20and=20rule=20levels?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- src/tui/components/ItemsEditor.tsx | 12 ++++++------ src/tui/components/items-editor/input-handlers.ts | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/tui/components/ItemsEditor.tsx b/src/tui/components/ItemsEditor.tsx index 21528b3c..7b6b79e4 100644 --- a/src/tui/components/ItemsEditor.tsx +++ b/src/tui/components/ItemsEditor.tsx @@ -341,11 +341,11 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB if (ruleEditorMode === 'color') { // Up/Down navigate rules if (key.upArrow) { - setRuleSelectedIndex(Math.max(0, ruleSelectedIndex - 1)); + setRuleSelectedIndex(ruleSelectedIndex > 0 ? ruleSelectedIndex - 1 : rules.length - 1); return; } if (key.downArrow) { - setRuleSelectedIndex(Math.min(rules.length - 1, ruleSelectedIndex + 1)); + setRuleSelectedIndex(ruleSelectedIndex < rules.length - 1 ? ruleSelectedIndex + 1 : 0); return; } @@ -407,11 +407,11 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB // 5. Rule property mode (the only remaining mode after 'color' above) // Up/Down navigate rules if (key.upArrow) { - setRuleSelectedIndex(Math.max(0, ruleSelectedIndex - 1)); + setRuleSelectedIndex(ruleSelectedIndex > 0 ? ruleSelectedIndex - 1 : rules.length - 1); return; } if (key.downArrow) { - setRuleSelectedIndex(Math.min(rules.length - 1, ruleSelectedIndex + 1)); + setRuleSelectedIndex(ruleSelectedIndex < rules.length - 1 ? ruleSelectedIndex + 1 : 0); return; } @@ -496,11 +496,11 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB if (widget) { // Up/Down for navigation (same as items mode) if (key.upArrow) { - setSelectedIndex(Math.max(0, selectedIndex - 1)); + setSelectedIndex(selectedIndex > 0 ? selectedIndex - 1 : widgets.length - 1); return; } if (key.downArrow) { - setSelectedIndex(Math.min(widgets.length - 1, selectedIndex + 1)); + setSelectedIndex(selectedIndex < widgets.length - 1 ? selectedIndex + 1 : 0); return; } diff --git a/src/tui/components/items-editor/input-handlers.ts b/src/tui/components/items-editor/input-handlers.ts index 4af5608d..35db4760 100644 --- a/src/tui/components/items-editor/input-handlers.ts +++ b/src/tui/components/items-editor/input-handlers.ts @@ -422,9 +422,9 @@ export function handleNormalInputMode({ toggleRulesExpansion }: HandleNormalInputModeArgs): void { if (key.upArrow && widgets.length > 0) { - setSelectedIndex(Math.max(0, selectedIndex - 1)); + setSelectedIndex(selectedIndex > 0 ? selectedIndex - 1 : widgets.length - 1); } else if (key.downArrow && widgets.length > 0) { - setSelectedIndex(Math.min(widgets.length - 1, selectedIndex + 1)); + setSelectedIndex(selectedIndex < widgets.length - 1 ? selectedIndex + 1 : 0); } else if (key.leftArrow && widgets.length > 0) { openWidgetPicker('change'); } else if (key.rightArrow && widgets.length > 0) { From 3a042757c956ce084bac00f126f0eb660eb2879a Mon Sep 17 00:00:00 2001 From: Mark O'Keefe Date: Mon, 23 Mar 2026 22:09:24 +1100 Subject: [PATCH 25/42] =?UTF-8?q?feat:=20move=20type=20picker=20to=20move?= =?UTF-8?q?=20mode,=20use=20=E2=86=90=E2=86=92=20for=20rules=20expand/coll?= =?UTF-8?q?apse?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Normal mode: → expands rules for selected widget, ← collapses if expanded. Move mode: ←→ opens the type picker. The `x` key no longer triggers rules expansion. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/tui/components/ItemsEditor.tsx | 4 ++- .../__tests__/input-handlers.test.ts | 8 +++++- .../components/items-editor/input-handlers.ts | 25 ++++++++++++------- 3 files changed, 26 insertions(+), 11 deletions(-) diff --git a/src/tui/components/ItemsEditor.tsx b/src/tui/components/ItemsEditor.tsx index 7b6b79e4..db597a6d 100644 --- a/src/tui/components/ItemsEditor.tsx +++ b/src/tui/components/ItemsEditor.tsx @@ -553,7 +553,8 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB selectedIndex, onUpdate, setSelectedIndex, - setMoveMode + setMoveMode, + openWidgetPicker }); return; } @@ -564,6 +565,7 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB widgets, selectedIndex, separatorChars, + expandedWidgetId, onBack, onUpdate, setSelectedIndex, diff --git a/src/tui/components/items-editor/__tests__/input-handlers.test.ts b/src/tui/components/items-editor/__tests__/input-handlers.test.ts index 1a8af85c..78e029f9 100644 --- a/src/tui/components/items-editor/__tests__/input-handlers.test.ts +++ b/src/tui/components/items-editor/__tests__/input-handlers.test.ts @@ -137,7 +137,8 @@ describe('items-editor input handlers', () => { selectedIndex: 1, onUpdate, setSelectedIndex, - setMoveMode + setMoveMode, + openWidgetPicker: vi.fn() }); expect(onUpdate).toHaveBeenCalledWith([ @@ -160,6 +161,7 @@ describe('items-editor input handlers', () => { widgets, selectedIndex: 0, separatorChars: ['|', '-'], + expandedWidgetId: null, onBack: vi.fn(), onUpdate, setSelectedIndex: vi.fn(), @@ -187,6 +189,7 @@ describe('items-editor input handlers', () => { widgets, selectedIndex: 0, separatorChars: ['|', '-'], + expandedWidgetId: null, onBack: vi.fn(), onUpdate, setSelectedIndex: vi.fn(), @@ -214,6 +217,7 @@ describe('items-editor input handlers', () => { widgets, selectedIndex: 0, separatorChars: ['|', '-'], + expandedWidgetId: null, onBack: vi.fn(), onUpdate, setSelectedIndex: vi.fn(), @@ -241,6 +245,7 @@ describe('items-editor input handlers', () => { widgets, selectedIndex: 0, separatorChars: ['|', '-'], + expandedWidgetId: null, onBack: vi.fn(), onUpdate, setSelectedIndex: vi.fn(), @@ -269,6 +274,7 @@ describe('items-editor input handlers', () => { widgets, selectedIndex: 0, separatorChars: ['|', '-'], + expandedWidgetId: null, onBack: vi.fn(), onUpdate, setSelectedIndex: vi.fn(), diff --git a/src/tui/components/items-editor/input-handlers.ts b/src/tui/components/items-editor/input-handlers.ts index 35db4760..023d6b85 100644 --- a/src/tui/components/items-editor/input-handlers.ts +++ b/src/tui/components/items-editor/input-handlers.ts @@ -285,6 +285,7 @@ export interface HandleMoveInputModeArgs { onUpdate: (widgets: WidgetItem[]) => void; setSelectedIndex: (index: number) => void; setMoveMode: (moveMode: boolean) => void; + openWidgetPicker: (action: WidgetPickerAction) => void; } export function handleMoveInputMode({ @@ -293,7 +294,8 @@ export function handleMoveInputMode({ selectedIndex, onUpdate, setSelectedIndex, - setMoveMode + setMoveMode, + openWidgetPicker }: HandleMoveInputModeArgs): void { if (key.upArrow && selectedIndex > 0) { const newWidgets = [...widgets]; @@ -313,6 +315,8 @@ export function handleMoveInputMode({ } onUpdate(newWidgets); setSelectedIndex(selectedIndex + 1); + } else if (key.leftArrow || key.rightArrow) { + openWidgetPicker('change'); } else if (key.escape || key.return) { setMoveMode(false); } @@ -394,6 +398,7 @@ export interface HandleNormalInputModeArgs { widgets: WidgetItem[]; selectedIndex: number; separatorChars: string[]; + expandedWidgetId: string | null; onBack: () => void; onUpdate: (widgets: WidgetItem[]) => void; setSelectedIndex: (index: number) => void; @@ -411,6 +416,7 @@ export function handleNormalInputMode({ widgets, selectedIndex, separatorChars, + expandedWidgetId, onBack, onUpdate, setSelectedIndex, @@ -425,10 +431,16 @@ export function handleNormalInputMode({ setSelectedIndex(selectedIndex > 0 ? selectedIndex - 1 : widgets.length - 1); } else if (key.downArrow && widgets.length > 0) { setSelectedIndex(selectedIndex < widgets.length - 1 ? selectedIndex + 1 : 0); - } else if (key.leftArrow && widgets.length > 0) { - openWidgetPicker('change'); } else if (key.rightArrow && widgets.length > 0) { - openWidgetPicker('change'); + const currentWidget = widgets[selectedIndex]; + if (currentWidget && currentWidget.type !== 'separator' && currentWidget.type !== 'flex-separator') { + toggleRulesExpansion(currentWidget); + } + } else if (key.leftArrow && widgets.length > 0) { + const currentWidget = widgets[selectedIndex]; + if (currentWidget?.id === expandedWidgetId) { + toggleRulesExpansion(currentWidget); + } } else if (key.return && widgets.length > 0) { setMoveMode(true); } else if (input === 'a') { @@ -453,11 +465,6 @@ export function handleNormalInputMode({ newWidgets[selectedIndex] = { ...currentWidget, character: nextChar }; onUpdate(newWidgets); } - } else if (input === 'x' && widgets.length > 0) { - const currentWidget = widgets[selectedIndex]; - if (currentWidget && currentWidget.type !== 'separator' && currentWidget.type !== 'flex-separator') { - toggleRulesExpansion(currentWidget); - } } else if (key.escape) { onBack(); } else if (widgets.length > 0) { From 84fbb29fb2afb9f4e82780eedbf314817a09b14f Mon Sep 17 00:00:00 2001 From: Mark O'Keefe Date: Mon, 23 Mar 2026 22:10:46 +1100 Subject: [PATCH 26/42] feat: update help text for relocated arrow key bindings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In items mode, replace '←→ open type picker' with '→ expand rules' and remove '(x) rules' since → now handles rule expansion directly. In move mode, add '←→ change type' alongside the existing move instructions. Co-Authored-By: Claude Sonnet 4.6 --- src/tui/components/ItemsEditor.tsx | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/tui/components/ItemsEditor.tsx b/src/tui/components/ItemsEditor.tsx index db597a6d..b3cf9c25 100644 --- a/src/tui/components/ItemsEditor.tsx +++ b/src/tui/components/ItemsEditor.tsx @@ -716,7 +716,7 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB // Items mode let text = hasWidgets - ? '↑↓ select, ←→ open type picker' + ? '↑↓ select, → expand rules' : '(a)dd via picker, (i)nsert via picker'; if (isSeparator) { text += ', Space edit separator'; @@ -730,9 +730,6 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB if (canMerge) { text += ', (m)erge'; } - if (!isSeparator && !isFlexSeparator && hasWidgets) { - text += ', (x) rules'; - } if (hasWidgets && !isSeparator && !isFlexSeparator) { text += ', Tab: color mode'; } @@ -874,7 +871,7 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB
{moveMode ? ( - ↑↓ to move widget, ESC or Enter to exit move mode + ↑↓ to move widget, ←→ change type, ESC or Enter to exit move mode ) : widgetPicker ? ( From 2ec66a79def22e3e9145e0fca449c81181c2d80c Mon Sep 17 00:00:00 2001 From: Mark O'Keefe Date: Mon, 23 Mar 2026 22:16:23 +1100 Subject: [PATCH 27/42] feat: show placeholder when expanded widget has no rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When an accordion is expanded (→ pressed) on a widget with zero rules, display an indented dimmed message '(no rules — press 'a' to add)' instead of rendering nothing. Co-Authored-By: Claude Sonnet 4.6 --- src/tui/components/ItemsEditor.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/tui/components/ItemsEditor.tsx b/src/tui/components/ItemsEditor.tsx index b3cf9c25..5b2f9cf1 100644 --- a/src/tui/components/ItemsEditor.tsx +++ b/src/tui/components/ItemsEditor.tsx @@ -1239,6 +1239,17 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB )} + {isExpanded && rules.length === 0 && ( + + + {' '} + + + {' '} + + (no rules — press 'a' to add) + + )} {isExpanded && rules.map((rule, ruleIndex) => { const isRuleSelected = ruleIndex === ruleSelectedIndex; const condition = formatCondition(rule.when); From e1a012e4e414ea0e226ac60f5cee67cde375f8dc Mon Sep 17 00:00:00 2001 From: Mark O'Keefe Date: Mon, 23 Mar 2026 22:22:49 +1100 Subject: [PATCH 28/42] feat: relocate condition editor trigger from rule property mode to move mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move ←→ condition editor opening from rule property mode to rule move mode (focus mode), mirroring the widget-level pattern. In property mode, ← now collapses rules and → is a no-op. In move mode, ←→ opens the condition editor and exits move mode. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/tui/components/ItemsEditor.tsx | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/src/tui/components/ItemsEditor.tsx b/src/tui/components/ItemsEditor.tsx index 5b2f9cf1..dbffb9ff 100644 --- a/src/tui/components/ItemsEditor.tsx +++ b/src/tui/components/ItemsEditor.tsx @@ -326,6 +326,14 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB // 3. Rule move mode if (ruleMoveMode) { + // Left/Right: open condition editor (exit move mode first) + if (key.leftArrow || key.rightArrow) { + if (rules.length > 0) { + setRuleConditionEditorIndex(ruleSelectedIndex); + setRuleMoveMode(false); + } + return; + } handleRuleMoveMode({ key, baseWidget: expandedWidget, @@ -464,11 +472,14 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB return; } - // Left/Right: open condition editor - if (key.leftArrow || key.rightArrow) { - if (rules.length > 0) { - setRuleConditionEditorIndex(ruleSelectedIndex); - } + // Left: collapse rules (same as ESC) + if (key.leftArrow) { + setExpandedWidgetId(null); + return; + } + + // Right: consumed but no-op (prevent fallthrough) + if (key.rightArrow) { return; } From 11b51220002dc911f667ba74513224ff7f6e1543 Mon Sep 17 00:00:00 2001 From: Mark O'Keefe Date: Mon, 23 Mar 2026 22:24:09 +1100 Subject: [PATCH 29/42] =?UTF-8?q?feat:=20relocate=20=E2=86=90=E2=86=92=20e?= =?UTF-8?q?dit=20condition=20hint=20to=20rule=20move=20mode=20help=20text?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove stale ←→ edit condition from rule property mode (where left arrow now collapses) and add it to rule move mode where the keybind actually lives. Also add ← collapse hint to rule property mode. Co-Authored-By: Claude Sonnet 4.6 --- src/tui/components/ItemsEditor.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tui/components/ItemsEditor.tsx b/src/tui/components/ItemsEditor.tsx index dbffb9ff..bf63eb8a 100644 --- a/src/tui/components/ItemsEditor.tsx +++ b/src/tui/components/ItemsEditor.tsx @@ -661,7 +661,7 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB // Rule-level modes take priority if (expandedWidgetId !== null) { if (ruleMoveMode) { - return '↑↓ move rule, Enter/ESC exit move mode'; + return '↑↓ move rule, ←→ edit condition, Enter/ESC exit move mode'; } if (ruleEditorMode === 'color') { @@ -684,7 +684,7 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB // Rule property mode const expandedWidget = widgets.find(w => w.id === expandedWidgetId); - let ruleText = '↑↓ select, ←→ edit condition, Enter move, (a)dd, (d)elete, (s)top, (h)ide, (c)lear properties, Tab: color mode, ESC: collapse'; + let ruleText = '↑↓ select, Enter move, (a)dd, (d)elete, (s)top, (h)ide, (c)lear properties, Tab: color mode, ← collapse, ESC: collapse'; if (expandedWidget && expandedWidget.type !== 'separator' && expandedWidget.type !== 'flex-separator') { const expandedWidgetImpl = getWidget(expandedWidget.type); From cc1dcbff68db2c19d1187e5cbe20e35c1cf997f7 Mon Sep 17 00:00:00 2001 From: Mark O'Keefe Date: Mon, 23 Mar 2026 22:31:00 +1100 Subject: [PATCH 30/42] =?UTF-8?q?feat:=20add=20=E2=86=90=E2=86=92=20arrow?= =?UTF-8?q?=20key=20navigation=20to=20LineSelector?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Right arrow drills into a line (same as Enter/select), left arrow goes back (same as ESC). Both are disabled during move mode and delete confirmation dialogs. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/tui/components/LineSelector.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/tui/components/LineSelector.tsx b/src/tui/components/LineSelector.tsx index 771d9ef7..9427ca59 100644 --- a/src/tui/components/LineSelector.tsx +++ b/src/tui/components/LineSelector.tsx @@ -141,9 +141,13 @@ const LineSelector: React.FC = ({ return; } - if (key.escape) { + if (key.escape || key.leftArrow) { onBack(); } + + if (key.rightArrow && localLines.length > 0 && selectedIndex < localLines.length) { + onSelect(selectedIndex); + } }); // Show powerline theme warning if applicable From ae4374a09e8d042b1a8a49ce5c206871b64291be Mon Sep 17 00:00:00 2001 From: Mark O'Keefe Date: Mon, 23 Mar 2026 22:32:15 +1100 Subject: [PATCH 31/42] =?UTF-8?q?feat:=20add=20=E2=86=90=20back-navigation?= =?UTF-8?q?=20from=20widget=20level=20in=20items=20editor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When at the widget level in items mode with no rules expanded, pressing ← now navigates back to LineSelector (same behavior as ESC). The existing collapse behavior (← when rules are expanded) is unchanged. Co-Authored-By: Claude Sonnet 4.6 --- src/tui/components/items-editor/input-handlers.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/tui/components/items-editor/input-handlers.ts b/src/tui/components/items-editor/input-handlers.ts index 023d6b85..a5c4963b 100644 --- a/src/tui/components/items-editor/input-handlers.ts +++ b/src/tui/components/items-editor/input-handlers.ts @@ -440,6 +440,8 @@ export function handleNormalInputMode({ const currentWidget = widgets[selectedIndex]; if (currentWidget?.id === expandedWidgetId) { toggleRulesExpansion(currentWidget); + } else if (expandedWidgetId === null) { + onBack(); } } else if (key.return && widgets.length > 0) { setMoveMode(true); From e5606a223fe07dab1368d85c1df8e4511ecff57a Mon Sep 17 00:00:00 2001 From: Mark O'Keefe Date: Mon, 23 Mar 2026 22:38:23 +1100 Subject: [PATCH 32/42] feat: update List component with wrap navigation, arrow key aliases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Default wrapNavigation to true so ↑↓ wraps around the list - Add onBack prop (optional); ← fires onBack when provided - Add → as alias for Enter to trigger onSelect on the selected item Co-Authored-By: Claude Sonnet 4.6 --- src/tui/components/List.tsx | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/tui/components/List.tsx b/src/tui/components/List.tsx index 48af0a29..ccd0ff3a 100644 --- a/src/tui/components/List.tsx +++ b/src/tui/components/List.tsx @@ -26,6 +26,7 @@ interface ListProps extends BoxProps { items: (ListEntry | '-')[]; onSelect: (value: V | 'back', index: number) => void; onSelectionChange?: (value: V | 'back', index: number) => void; + onBack?: () => void; initialSelection?: number; showBackButton?: boolean; color?: ForegroundColorName; @@ -36,10 +37,11 @@ export function List({ items, onSelect, onSelectionChange, + onBack, initialSelection = 0, showBackButton, color, - wrapNavigation = false, + wrapNavigation = true, ...boxProps }: ListProps) { const [selectedIndex, setSelectedIndex] = useState(initialSelection); @@ -97,6 +99,16 @@ export function List({ onSelect(selectedItem.value, selectedIndex); return; } + + if (key.rightArrow && selectedItem) { + onSelect(selectedItem.value, selectedIndex); + return; + } + + if (key.leftArrow) { + if (onBack) { onBack(); } + return; + } }); return ( From 1f6f4c52e5d5ddb6f9d16aab50c431d04a7677df Mon Sep 17 00:00:00 2001 From: Mark O'Keefe Date: Mon, 23 Mar 2026 22:40:13 +1100 Subject: [PATCH 33/42] =?UTF-8?q?refactor(LineSelector):=20remove=20redund?= =?UTF-8?q?ant=20=E2=86=90=E2=86=92=20handlers,=20wire=20onBack=20to=20Lis?= =?UTF-8?q?t?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The List component now handles ← (fires onBack) and → (fires onSelect) natively. Remove the duplicate leftArrow/rightArrow handlers from LineSelector's useInput and pass onBack={onBack} to the List so the built-in navigation takes over. ESC still calls onBack via useInput for the non-List cases (move mode, theme-managed). Co-Authored-By: Claude Sonnet 4.6 --- src/tui/components/LineSelector.tsx | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/tui/components/LineSelector.tsx b/src/tui/components/LineSelector.tsx index 9427ca59..0275569a 100644 --- a/src/tui/components/LineSelector.tsx +++ b/src/tui/components/LineSelector.tsx @@ -141,13 +141,9 @@ const LineSelector: React.FC = ({ return; } - if (key.escape || key.leftArrow) { + if (key.escape) { onBack(); } - - if (key.rightArrow && localLines.length > 0 && selectedIndex < localLines.length) { - onSelect(selectedIndex); - } }); // Show powerline theme warning if applicable @@ -302,6 +298,7 @@ const LineSelector: React.FC = ({ onSelectionChange={(_, index) => { setSelectedIndex(index); }} + onBack={onBack} initialSelection={selectedIndex} showBackButton={true} /> From 9099ab1c75216137b115fd705cb28afe4002c23d Mon Sep 17 00:00:00 2001 From: Mark O'Keefe Date: Mon, 23 Mar 2026 22:46:46 +1100 Subject: [PATCH 34/42] =?UTF-8?q?feat:=20wire=20onBack=20to=20List=20in=20?= =?UTF-8?q?all=20sub-screens=20for=20=E2=86=90=20arrow=20navigation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pass onBack to the List component in PowerlineSetup, TerminalOptionsMenu, TerminalWidthMenu, InstallMenu, and PowerlineThemeSelector so that ← exits these screens, matching existing ESC behavior. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/tui/components/InstallMenu.tsx | 1 + src/tui/components/PowerlineSetup.tsx | 1 + src/tui/components/PowerlineThemeSelector.tsx | 4 ++++ src/tui/components/TerminalOptionsMenu.tsx | 1 + src/tui/components/TerminalWidthMenu.tsx | 1 + 5 files changed, 8 insertions(+) diff --git a/src/tui/components/InstallMenu.tsx b/src/tui/components/InstallMenu.tsx index 093ab521..3aa39162 100644 --- a/src/tui/components/InstallMenu.tsx +++ b/src/tui/components/InstallMenu.tsx @@ -83,6 +83,7 @@ export const InstallMenu: React.FC = ({ color='blue' marginTop={1} items={listItems} + onBack={onCancel} onSelect={(line) => { if (line === 'back') { onCancel(); diff --git a/src/tui/components/PowerlineSetup.tsx b/src/tui/components/PowerlineSetup.tsx index c00c281d..9cf5999e 100644 --- a/src/tui/components/PowerlineSetup.tsx +++ b/src/tui/components/PowerlineSetup.tsx @@ -422,6 +422,7 @@ export const PowerlineSetup: React.FC = ({ { if (value === 'back') { onBack(); diff --git a/src/tui/components/PowerlineThemeSelector.tsx b/src/tui/components/PowerlineThemeSelector.tsx index ed2eb8b4..0d0a74bb 100644 --- a/src/tui/components/PowerlineThemeSelector.tsx +++ b/src/tui/components/PowerlineThemeSelector.tsx @@ -207,6 +207,10 @@ export const PowerlineThemeSelector: React.FC = ({ { + onUpdate(originalSettingsRef.current); + onBack(); + }} onSelect={() => { onBack(); }} diff --git a/src/tui/components/TerminalOptionsMenu.tsx b/src/tui/components/TerminalOptionsMenu.tsx index 27c75c3b..f7c263a5 100644 --- a/src/tui/components/TerminalOptionsMenu.tsx +++ b/src/tui/components/TerminalOptionsMenu.tsx @@ -154,6 +154,7 @@ export const TerminalOptionsMenu: React.FC = ({ marginTop={1} items={buildTerminalOptionsItems(settings.colorLevel)} onSelect={handleSelect} + onBack={onBack} showBackButton={true} /> diff --git a/src/tui/components/TerminalWidthMenu.tsx b/src/tui/components/TerminalWidthMenu.tsx index 01a4dc7f..72e3d5fe 100644 --- a/src/tui/components/TerminalWidthMenu.tsx +++ b/src/tui/components/TerminalWidthMenu.tsx @@ -150,6 +150,7 @@ export const TerminalWidthMenu: React.FC = ({ marginTop={1} items={buildTerminalWidthItems(selectedOption, compactThreshold)} initialSelection={getTerminalWidthSelectionIndex(selectedOption)} + onBack={onBack} onSelect={(value) => { if (value === 'back') { onBack(); From 450cc74f9d5ac712aa373f7434270cfc045b8f17 Mon Sep 17 00:00:00 2001 From: Mark O'Keefe Date: Mon, 23 Mar 2026 22:49:32 +1100 Subject: [PATCH 35/42] =?UTF-8?q?feat:=20add=20=E2=86=90=20as=20back-navig?= =?UTF-8?q?ation=20key=20to=20GlobalOverridesMenu=20and=20PowerlineSeparat?= =?UTF-8?q?orEditor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds key.leftArrow alongside key.escape in both non-List screens so users can press ← to navigate back. In edit/hex-input modes, ← cancels the edit (matching ESC). In PowerlineSeparatorEditor normal mode, ← is already used for cycling presets so only hex-input-mode cancel is added there. Co-Authored-By: Claude Sonnet 4.6 --- src/tui/components/GlobalOverridesMenu.tsx | 6 +++--- src/tui/components/PowerlineSeparatorEditor.tsx | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/tui/components/GlobalOverridesMenu.tsx b/src/tui/components/GlobalOverridesMenu.tsx index ebc3a04d..a0644897 100644 --- a/src/tui/components/GlobalOverridesMenu.tsx +++ b/src/tui/components/GlobalOverridesMenu.tsx @@ -51,7 +51,7 @@ export const GlobalOverridesMenu: React.FC = ({ settin }; onUpdate(updatedSettings); setEditingPadding(false); - } else if (key.escape) { + } else if (key.escape || key.leftArrow) { setPaddingInput(settings.defaultPadding ?? ''); setEditingPadding(false); } else if (key.backspace) { @@ -80,7 +80,7 @@ export const GlobalOverridesMenu: React.FC = ({ settin onUpdate(updatedSettings); setEditingSeparator(false); } - } else if (key.escape) { + } else if (key.escape || key.leftArrow) { setSeparatorInput(settings.defaultSeparator ?? ''); setEditingSeparator(false); } else if (key.backspace) { @@ -94,7 +94,7 @@ export const GlobalOverridesMenu: React.FC = ({ settin // Skip input handling when confirmation is active - let ConfirmDialog handle it return; } else { - if (key.escape) { + if (key.escape || key.leftArrow) { onBack(); } else if (input === 'p' || input === 'P') { setEditingPadding(true); diff --git a/src/tui/components/PowerlineSeparatorEditor.tsx b/src/tui/components/PowerlineSeparatorEditor.tsx index c14021f1..558d79b9 100644 --- a/src/tui/components/PowerlineSeparatorEditor.tsx +++ b/src/tui/components/PowerlineSeparatorEditor.tsx @@ -113,7 +113,7 @@ export const PowerlineSeparatorEditor: React.FC = useInput((input, key) => { if (hexInputMode) { // Hex input mode - if (key.escape) { + if (key.escape || key.leftArrow) { setHexInputMode(false); setHexInput(''); setCursorPos(0); From 8e9dad19020b32277d595244db426694594aa9cc Mon Sep 17 00:00:00 2001 From: Mark O'Keefe Date: Mon, 23 Mar 2026 22:52:44 +1100 Subject: [PATCH 36/42] refactor: remove redundant showBackButton and back value handlers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With ← arrow key now handling back navigation via List's onBack prop, the "← Back" menu items are no longer needed. Remove showBackButton prop from List, the useMemo that appended back items, and all dead 'back' value checks in onSelect/onSelectionChange handlers. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/tui/components/InstallMenu.tsx | 9 --------- src/tui/components/LineSelector.tsx | 6 ------ src/tui/components/List.tsx | 20 +++++-------------- src/tui/components/MainMenu.tsx | 4 ---- src/tui/components/PowerlineSetup.tsx | 6 ------ src/tui/components/PowerlineThemeSelector.tsx | 4 ---- src/tui/components/TerminalOptionsMenu.tsx | 8 +------- src/tui/components/TerminalWidthMenu.tsx | 6 ------ 8 files changed, 6 insertions(+), 57 deletions(-) diff --git a/src/tui/components/InstallMenu.tsx b/src/tui/components/InstallMenu.tsx index 3aa39162..cd70f0dd 100644 --- a/src/tui/components/InstallMenu.tsx +++ b/src/tui/components/InstallMenu.tsx @@ -42,9 +42,6 @@ export const InstallMenu: React.FC = ({ onSelectBunx(); } break; - case 'back': - onCancel(); - break; } } @@ -85,15 +82,9 @@ export const InstallMenu: React.FC = ({ items={listItems} onBack={onCancel} onSelect={(line) => { - if (line === 'back') { - onCancel(); - return; - } - onSelect(line); }} initialSelection={initialSelection} - showBackButton={true} /> diff --git a/src/tui/components/LineSelector.tsx b/src/tui/components/LineSelector.tsx index 0275569a..42553e2a 100644 --- a/src/tui/components/LineSelector.tsx +++ b/src/tui/components/LineSelector.tsx @@ -288,11 +288,6 @@ const LineSelector: React.FC = ({ marginTop={1} items={lineItems} onSelect={(line) => { - if (line === 'back') { - onBack(); - return; - } - onSelect(line); }} onSelectionChange={(_, index) => { @@ -300,7 +295,6 @@ const LineSelector: React.FC = ({ }} onBack={onBack} initialSelection={selectedIndex} - showBackButton={true} /> )} diff --git a/src/tui/components/List.tsx b/src/tui/components/List.tsx index ccd0ff3a..325bc880 100644 --- a/src/tui/components/List.tsx +++ b/src/tui/components/List.tsx @@ -7,7 +7,6 @@ import { } from 'ink'; import { useEffect, - useMemo, useRef, useState, type PropsWithChildren @@ -24,11 +23,10 @@ export interface ListEntry { interface ListProps extends BoxProps { items: (ListEntry | '-')[]; - onSelect: (value: V | 'back', index: number) => void; - onSelectionChange?: (value: V | 'back', index: number) => void; + onSelect: (value: V, index: number) => void; + onSelectionChange?: (value: V, index: number) => void; onBack?: () => void; initialSelection?: number; - showBackButton?: boolean; color?: ForegroundColorName; wrapNavigation?: boolean; } @@ -39,7 +37,6 @@ export function List({ onSelectionChange, onBack, initialSelection = 0, - showBackButton, color, wrapNavigation = true, ...boxProps @@ -47,17 +44,10 @@ export function List({ const [selectedIndex, setSelectedIndex] = useState(initialSelection); const latestOnSelectionChangeRef = useRef(onSelectionChange); - const _items = useMemo(() => { - if (showBackButton) { - return [...items, '-' as const, { label: '← Back', value: 'back' as V }]; - } - return items; - }, [items, showBackButton]); - - const selectableItems = _items.filter(item => item !== '-' && !item.disabled) as ListEntry[]; + const selectableItems = items.filter(item => item !== '-' && !item.disabled) as ListEntry[]; const selectedItem = selectableItems[selectedIndex]; const selectedValue = selectedItem?.value; - const actualIndex = _items.findIndex(item => item === selectedItem); + const actualIndex = items.findIndex(item => item === selectedItem); useEffect(() => { latestOnSelectionChangeRef.current = onSelectionChange; @@ -113,7 +103,7 @@ export function List({ return ( - {_items.map((item, index) => { + {items.map((item, index) => { if (item === '-') { return ; } diff --git a/src/tui/components/MainMenu.tsx b/src/tui/components/MainMenu.tsx index 51dd254e..aa648c91 100644 --- a/src/tui/components/MainMenu.tsx +++ b/src/tui/components/MainMenu.tsx @@ -135,10 +135,6 @@ export const MainMenu: React.FC = ({ items={menuItems} marginTop={1} onSelect={(value, index) => { - if (value === 'back') { - return; - } - onSelect(value, index); }} initialSelection={initialSelection} diff --git a/src/tui/components/PowerlineSetup.tsx b/src/tui/components/PowerlineSetup.tsx index 9cf5999e..7b45cee8 100644 --- a/src/tui/components/PowerlineSetup.tsx +++ b/src/tui/components/PowerlineSetup.tsx @@ -424,18 +424,12 @@ export const PowerlineSetup: React.FC = ({ items={buildPowerlineSetupMenuItems(powerlineConfig)} onBack={onBack} onSelect={(value) => { - if (value === 'back') { - onBack(); - return; - } - setScreen(value); }} onSelectionChange={(_, index) => { setSelectedMenuItem(index); }} initialSelection={selectedMenuItem} - showBackButton={true} /> )} diff --git a/src/tui/components/PowerlineThemeSelector.tsx b/src/tui/components/PowerlineThemeSelector.tsx index 0d0a74bb..4489a432 100644 --- a/src/tui/components/PowerlineThemeSelector.tsx +++ b/src/tui/components/PowerlineThemeSelector.tsx @@ -215,10 +215,6 @@ export const PowerlineThemeSelector: React.FC = ({ onBack(); }} onSelectionChange={(themeName, index) => { - if (themeName === 'back') { - return; - } - setSelectedIndex(index); }} initialSelection={selectedIndex} diff --git a/src/tui/components/TerminalOptionsMenu.tsx b/src/tui/components/TerminalOptionsMenu.tsx index f7c263a5..c7ed36ae 100644 --- a/src/tui/components/TerminalOptionsMenu.tsx +++ b/src/tui/components/TerminalOptionsMenu.tsx @@ -72,12 +72,7 @@ export const TerminalOptionsMenu: React.FC = ({ const [showColorWarning, setShowColorWarning] = useState(false); const [pendingColorLevel, setPendingColorLevel] = useState<0 | 1 | 2 | 3 | null>(null); - const handleSelect = (value: TerminalOptionsValue | 'back') => { - if (value === 'back') { - onBack(); - return; - } - + const handleSelect = (value: TerminalOptionsValue) => { if (value === 'width') { onBack('width'); return; @@ -155,7 +150,6 @@ export const TerminalOptionsMenu: React.FC = ({ items={buildTerminalOptionsItems(settings.colorLevel)} onSelect={handleSelect} onBack={onBack} - showBackButton={true} /> )} diff --git a/src/tui/components/TerminalWidthMenu.tsx b/src/tui/components/TerminalWidthMenu.tsx index 72e3d5fe..c4f432b4 100644 --- a/src/tui/components/TerminalWidthMenu.tsx +++ b/src/tui/components/TerminalWidthMenu.tsx @@ -152,11 +152,6 @@ export const TerminalWidthMenu: React.FC = ({ initialSelection={getTerminalWidthSelectionIndex(selectedOption)} onBack={onBack} onSelect={(value) => { - if (value === 'back') { - onBack(); - return; - } - setSelectedOption(value); const updatedSettings = { @@ -170,7 +165,6 @@ export const TerminalWidthMenu: React.FC = ({ setEditingThreshold(true); } }} - showBackButton={true} /> )} From 3082a145ca6ea37993e22e30c5c5da97d4a3fb4a Mon Sep 17 00:00:00 2001 From: Mark O'Keefe Date: Mon, 23 Mar 2026 22:56:55 +1100 Subject: [PATCH 37/42] feat: add confirmation dialog when exiting with unsaved changes Co-Authored-By: Claude Opus 4.6 (1M context) --- src/tui/App.tsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/tui/App.tsx b/src/tui/App.tsx index 7b4fd8e8..92763ec7 100644 --- a/src/tui/App.tsx +++ b/src/tui/App.tsx @@ -291,7 +291,18 @@ export const App: React.FC = () => { exit(); break; case 'exit': - exit(); + if (hasChanges) { + setConfirmDialog({ + message: 'You have unsaved changes. Exit without saving?', + action: () => { + exit(); + return Promise.resolve(); + } + }); + setScreen('confirm'); + } else { + exit(); + } break; } }; From bf74a28a14b78e9b5b3c6347c7be841b7ac660bc Mon Sep 17 00:00:00 2001 From: Mark O'Keefe Date: Mon, 23 Mar 2026 23:01:59 +1100 Subject: [PATCH 38/42] =?UTF-8?q?feat:=20move=20condition=20editor=20trigg?= =?UTF-8?q?er=20from=20rule=20move=20mode=20=E2=86=90=E2=86=92=20to=20rule?= =?UTF-8?q?=20property=20mode=20=E2=86=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rule property mode: → now opens condition editor (was a silent no-op) - Rule move mode: removed ←→ handler; ↑↓ reorder and Enter/ESC exit only Co-Authored-By: Claude Sonnet 4.6 --- src/tui/components/ItemsEditor.tsx | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/src/tui/components/ItemsEditor.tsx b/src/tui/components/ItemsEditor.tsx index bf63eb8a..5a28cf1e 100644 --- a/src/tui/components/ItemsEditor.tsx +++ b/src/tui/components/ItemsEditor.tsx @@ -326,14 +326,6 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB // 3. Rule move mode if (ruleMoveMode) { - // Left/Right: open condition editor (exit move mode first) - if (key.leftArrow || key.rightArrow) { - if (rules.length > 0) { - setRuleConditionEditorIndex(ruleSelectedIndex); - setRuleMoveMode(false); - } - return; - } handleRuleMoveMode({ key, baseWidget: expandedWidget, @@ -478,8 +470,11 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB return; } - // Right: consumed but no-op (prevent fallthrough) + // Right: open condition editor if (key.rightArrow) { + if (rules.length > 0) { + setRuleConditionEditorIndex(ruleSelectedIndex); + } return; } From d324d99a3cdc3976dae5b38b70cbb8ce571c5d98 Mon Sep 17 00:00:00 2001 From: Mark O'Keefe Date: Mon, 23 Mar 2026 23:03:26 +1100 Subject: [PATCH 39/42] feat: update rule-level help text in ItemsEditor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In property mode, show '→ edit condition' so users know how to access condition editing. In move mode, remove the stale '←→ edit condition' reference since that shortcut no longer applies there. Co-Authored-By: Claude Sonnet 4.6 --- src/tui/components/ItemsEditor.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tui/components/ItemsEditor.tsx b/src/tui/components/ItemsEditor.tsx index 5a28cf1e..70ac833c 100644 --- a/src/tui/components/ItemsEditor.tsx +++ b/src/tui/components/ItemsEditor.tsx @@ -656,7 +656,7 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB // Rule-level modes take priority if (expandedWidgetId !== null) { if (ruleMoveMode) { - return '↑↓ move rule, ←→ edit condition, Enter/ESC exit move mode'; + return '↑↓ move rule, Enter/ESC exit move mode'; } if (ruleEditorMode === 'color') { @@ -679,7 +679,7 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB // Rule property mode const expandedWidget = widgets.find(w => w.id === expandedWidgetId); - let ruleText = '↑↓ select, Enter move, (a)dd, (d)elete, (s)top, (h)ide, (c)lear properties, Tab: color mode, ← collapse, ESC: collapse'; + let ruleText = '↑↓ select, → edit condition, Enter move, (a)dd, (d)elete, (s)top, (h)ide, (c)lear properties, Tab: color mode, ← collapse, ESC: collapse'; if (expandedWidget && expandedWidget.type !== 'separator' && expandedWidget.type !== 'flex-separator') { const expandedWidgetImpl = getWidget(expandedWidget.type); From b9add83bfec7186df8709d7ceca92d1616ee1e1c Mon Sep 17 00:00:00 2001 From: Mark O'Keefe Date: Mon, 23 Mar 2026 23:23:07 +1100 Subject: [PATCH 40/42] feat: refactor PowerlineSeparatorEditor to use Enter/focus mode for editing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Free ← for back navigation by moving preset cycling and toggle-invert into a focus mode entered via Enter. Normal mode now uses wrapping ↑↓ navigation and ← for back. Focus mode provides ←→ preset cycling, ↑↓ reorder, and (t)oggle invert for separators. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../components/PowerlineSeparatorEditor.tsx | 86 +++++++++++++------ 1 file changed, 60 insertions(+), 26 deletions(-) diff --git a/src/tui/components/PowerlineSeparatorEditor.tsx b/src/tui/components/PowerlineSeparatorEditor.tsx index 558d79b9..6646aea6 100644 --- a/src/tui/components/PowerlineSeparatorEditor.tsx +++ b/src/tui/components/PowerlineSeparatorEditor.tsx @@ -43,6 +43,7 @@ export const PowerlineSeparatorEditor: React.FC = : []; const [selectedIndex, setSelectedIndex] = useState(0); + const [focusMode, setFocusMode] = useState(false); const [hexInputMode, setHexInputMode] = useState(false); const [hexInput, setHexInput] = useState(''); const [cursorPos, setCursorPos] = useState(0); @@ -142,14 +143,10 @@ export const PowerlineSeparatorEditor: React.FC = setHexInput(hexInput.slice(0, cursorPos) + input.toUpperCase() + hexInput.slice(cursorPos)); setCursorPos(cursorPos + 1); } - } else { - // Normal mode - if (key.escape) { - onBack(); - } else if (key.upArrow) { - setSelectedIndex(Math.max(0, selectedIndex - 1)); - } else if (key.downArrow && separators.length > 0) { - setSelectedIndex(Math.min(separators.length - 1, selectedIndex + 1)); + } else if (focusMode) { + // Focus mode: edit the selected separator + if (key.escape || key.return) { + setFocusMode(false); } else if ((key.leftArrow || key.rightArrow) && separators.length > 0) { // Cycle through preset separators const currentChar = separators[selectedIndex] ?? '\uE0B0'; @@ -159,18 +156,16 @@ export const PowerlineSeparatorEditor: React.FC = let newIndex; if (currentPresetIndex !== -1) { - // It's a preset, cycle to next/prev preset if (key.rightArrow) { newIndex = (currentPresetIndex + 1) % presetSeparators.length; } else { newIndex = currentPresetIndex === 0 ? presetSeparators.length - 1 : currentPresetIndex - 1; } } else { - // It's a custom separator, cycle to first or last preset if (key.rightArrow) { - newIndex = 0; // Go to first preset + newIndex = 0; } else { - newIndex = presetSeparators.length - 1; // Go to last preset + newIndex = presetSeparators.length - 1; } } @@ -184,6 +179,54 @@ export const PowerlineSeparatorEditor: React.FC = } updateSeparators(newSeparators, mode === 'separator' ? newInvertBgs : undefined); + } else if (key.upArrow && separators.length > 1) { + // Reorder: move selected item up + if (selectedIndex > 0) { + const newSeparators = [...separators]; + const newInvertBgs = mode === 'separator' ? [...invertBgs] : []; + const temp = newSeparators[selectedIndex - 1] ?? ''; + newSeparators[selectedIndex - 1] = newSeparators[selectedIndex] ?? ''; + newSeparators[selectedIndex] = temp; + if (mode === 'separator') { + const tempInvert = newInvertBgs[selectedIndex - 1] ?? false; + newInvertBgs[selectedIndex - 1] = newInvertBgs[selectedIndex] ?? false; + newInvertBgs[selectedIndex] = tempInvert; + } + updateSeparators(newSeparators, mode === 'separator' ? newInvertBgs : undefined); + setSelectedIndex(selectedIndex - 1); + } + } else if (key.downArrow && separators.length > 1) { + // Reorder: move selected item down + if (selectedIndex < separators.length - 1) { + const newSeparators = [...separators]; + const newInvertBgs = mode === 'separator' ? [...invertBgs] : []; + const temp = newSeparators[selectedIndex + 1] ?? ''; + newSeparators[selectedIndex + 1] = newSeparators[selectedIndex] ?? ''; + newSeparators[selectedIndex] = temp; + if (mode === 'separator') { + const tempInvert = newInvertBgs[selectedIndex + 1] ?? false; + newInvertBgs[selectedIndex + 1] = newInvertBgs[selectedIndex] ?? false; + newInvertBgs[selectedIndex] = tempInvert; + } + updateSeparators(newSeparators, mode === 'separator' ? newInvertBgs : undefined); + setSelectedIndex(selectedIndex + 1); + } + } else if ((input === 't' || input === 'T') && mode === 'separator') { + // Toggle background inversion + const newInvertBgs = [...invertBgs]; + newInvertBgs[selectedIndex] = !(newInvertBgs[selectedIndex] ?? false); + updateSeparators(separators, newInvertBgs); + } + } else { + // Normal mode + if (key.escape || key.leftArrow) { + onBack(); + } else if (key.upArrow && separators.length > 0) { + setSelectedIndex(selectedIndex <= 0 ? separators.length - 1 : selectedIndex - 1); + } else if (key.downArrow && separators.length > 0) { + setSelectedIndex(selectedIndex >= separators.length - 1 ? 0 : selectedIndex + 1); + } else if (key.return && separators.length > 0) { + setFocusMode(true); } else if ((input === 'a' || input === 'A') && (mode === 'separator' || separators.length < 3)) { // Add after current (max 3 for caps) const newSeparators = [...separators]; @@ -191,7 +234,6 @@ export const PowerlineSeparatorEditor: React.FC = const defaultChar = presetSeparators[0]?.char ?? '\uE0B0'; const isLeftFacing = defaultChar === '\uE0B2' || defaultChar === '\uE0B6'; if (separators.length === 0) { - // If empty, just add at the beginning newSeparators.push(defaultChar); if (mode === 'separator') { newInvertBgs.push(isLeftFacing); @@ -199,7 +241,6 @@ export const PowerlineSeparatorEditor: React.FC = updateSeparators(newSeparators, newInvertBgs); setSelectedIndex(0); } else { - // Add after current selected item newSeparators.splice(selectedIndex + 1, 0, defaultChar); if (mode === 'separator') { newInvertBgs.splice(selectedIndex + 1, 0, isLeftFacing); @@ -214,7 +255,6 @@ export const PowerlineSeparatorEditor: React.FC = const defaultChar = presetSeparators[0]?.char ?? '\uE0B0'; const isLeftFacing = defaultChar === '\uE0B2' || defaultChar === '\uE0B6'; if (separators.length === 0) { - // If empty, just add at the beginning newSeparators.push(defaultChar); if (mode === 'separator') { newInvertBgs.push(isLeftFacing); @@ -222,13 +262,11 @@ export const PowerlineSeparatorEditor: React.FC = updateSeparators(newSeparators, newInvertBgs); setSelectedIndex(0); } else { - // Insert before current selected item newSeparators.splice(selectedIndex, 0, defaultChar); if (mode === 'separator') { newInvertBgs.splice(selectedIndex, 0, isLeftFacing); } updateSeparators(newSeparators, newInvertBgs); - // Keep selection on the newly inserted item (which is now at selectedIndex) } } else if ((input === 'd' || input === 'D') && (mode !== 'separator' || separators.length > 1)) { // Delete current (min 1 for separator, no min for caps) @@ -239,7 +277,6 @@ export const PowerlineSeparatorEditor: React.FC = } else if (input === 'c' || input === 'C') { // Clear all if (mode === 'separator') { - // Reset to default right-facing separator with no inversion updateSeparators(['\uE0B0'], [false]); } else { updateSeparators([]); @@ -250,11 +287,6 @@ export const PowerlineSeparatorEditor: React.FC = setHexInputMode(true); setHexInput(''); setCursorPos(0); - } else if ((input === 't' || input === 'T') && mode === 'separator') { - // Toggle background inversion (for all separators in separator mode) - const newInvertBgs = [...invertBgs]; - newInvertBgs[selectedIndex] = !(newInvertBgs[selectedIndex] ?? false); - updateSeparators(separators, newInvertBgs); } } }); @@ -300,7 +332,9 @@ export const PowerlineSeparatorEditor: React.FC = <> - {`↑↓ select, ← → cycle${canAdd ? ', (a)dd, (i)nsert' : ''}${canDelete ? ', (d)elete' : ''}, (c)lear, (h)ex${mode === 'separator' ? ', (t)oggle invert' : ''}, ESC back`} + {focusMode + ? `←→ cycle preset, ↑↓ reorder${mode === 'separator' ? ', (t)oggle invert' : ''}, Enter/ESC exit` + : `↑↓ select, Enter: edit${canAdd ? ', (a)dd, (i)nsert' : ''}${canDelete ? ', (d)elete' : ''}, (c)lear, (h)ex, ← back`} @@ -308,8 +342,8 @@ export const PowerlineSeparatorEditor: React.FC = {separators.length > 0 ? ( separators.map((sep, index) => ( - - {index === selectedIndex ? '▶ ' : ' '} + + {index === selectedIndex ? (focusMode ? '✎ ' : '▶ ') : ' '} {`${index + 1}: ${getSeparatorDisplay(sep, index)}`} From 29b2d18677b4f69185f373d1dfd024aa97eff9b6 Mon Sep 17 00:00:00 2001 From: Mark O'Keefe Date: Mon, 23 Mar 2026 23:26:10 +1100 Subject: [PATCH 41/42] refactor: normalize keybind matching to lowercase across TUI screens Add `const cmd = input.toLowerCase()` at the start of keybind sections in GlobalOverridesMenu, PowerlineSetup, PowerlineSeparatorEditor, and PowerlineThemeSelector. Replace all `input === 'x' || input === 'X'` patterns with `cmd === 'x'`. Text input modes (hex digits, padding, separator text) continue to use the original `input` to preserve case. Co-Authored-By: Claude Sonnet 4.6 --- src/tui/components/GlobalOverridesMenu.tsx | 17 ++++++------- .../components/PowerlineSeparatorEditor.tsx | 24 +++++++++++-------- src/tui/components/PowerlineSetup.tsx | 7 +++--- src/tui/components/PowerlineThemeSelector.tsx | 3 ++- 4 files changed, 29 insertions(+), 22 deletions(-) diff --git a/src/tui/components/GlobalOverridesMenu.tsx b/src/tui/components/GlobalOverridesMenu.tsx index a0644897..0505fd30 100644 --- a/src/tui/components/GlobalOverridesMenu.tsx +++ b/src/tui/components/GlobalOverridesMenu.tsx @@ -94,13 +94,14 @@ export const GlobalOverridesMenu: React.FC = ({ settin // Skip input handling when confirmation is active - let ConfirmDialog handle it return; } else { + const cmd = input.toLowerCase(); if (key.escape || key.leftArrow) { onBack(); - } else if (input === 'p' || input === 'P') { + } else if (cmd === 'p') { setEditingPadding(true); - } else if ((input === 's' || input === 'S') && !isPowerlineEnabled && !key.ctrl) { + } else if (cmd === 's' && !isPowerlineEnabled && !key.ctrl) { setEditingSeparator(true); - } else if ((input === 'i' || input === 'I') && !isPowerlineEnabled) { + } else if (cmd === 'i' && !isPowerlineEnabled) { const newInheritColors = !inheritColors; setInheritColors(newInheritColors); const updatedSettings = { @@ -108,7 +109,7 @@ export const GlobalOverridesMenu: React.FC = ({ settin inheritSeparatorColors: newInheritColors }; onUpdate(updatedSettings); - } else if ((input === 'b' || input === 'B') && !isPowerlineEnabled) { + } else if (cmd === 'b' && !isPowerlineEnabled) { // Cycle through background colors const nextIndex = (currentBgIndex + 1) % bgColors.length; const nextBgColor = bgColors[nextIndex]; @@ -117,14 +118,14 @@ export const GlobalOverridesMenu: React.FC = ({ settin overrideBackgroundColor: nextBgColor === 'none' ? undefined : nextBgColor }; onUpdate(updatedSettings); - } else if ((input === 'c' || input === 'C') && !isPowerlineEnabled) { + } else if (cmd === 'c' && !isPowerlineEnabled) { // Clear override background color const updatedSettings = { ...settings, overrideBackgroundColor: undefined }; onUpdate(updatedSettings); - } else if (input === 'o' || input === 'O') { + } else if (cmd === 'o') { // Toggle global bold const newGlobalBold = !globalBold; setGlobalBold(newGlobalBold); @@ -133,7 +134,7 @@ export const GlobalOverridesMenu: React.FC = ({ settin globalBold: newGlobalBold }; onUpdate(updatedSettings); - } else if (input === 'f' || input === 'F') { + } else if (cmd === 'f') { // Cycle through foreground colors const nextIndex = (currentFgIndex + 1) % fgColors.length; const nextFgColor = fgColors[nextIndex]; @@ -142,7 +143,7 @@ export const GlobalOverridesMenu: React.FC = ({ settin overrideForegroundColor: nextFgColor === 'none' ? undefined : nextFgColor }; onUpdate(updatedSettings); - } else if (input === 'g' || input === 'G') { + } else if (cmd === 'g') { // Clear override foreground color const updatedSettings = { ...settings, diff --git a/src/tui/components/PowerlineSeparatorEditor.tsx b/src/tui/components/PowerlineSeparatorEditor.tsx index 6646aea6..11837d49 100644 --- a/src/tui/components/PowerlineSeparatorEditor.tsx +++ b/src/tui/components/PowerlineSeparatorEditor.tsx @@ -211,14 +211,18 @@ export const PowerlineSeparatorEditor: React.FC = updateSeparators(newSeparators, mode === 'separator' ? newInvertBgs : undefined); setSelectedIndex(selectedIndex + 1); } - } else if ((input === 't' || input === 'T') && mode === 'separator') { - // Toggle background inversion - const newInvertBgs = [...invertBgs]; - newInvertBgs[selectedIndex] = !(newInvertBgs[selectedIndex] ?? false); - updateSeparators(separators, newInvertBgs); + } else { + const cmd = input.toLowerCase(); + if (cmd === 't' && mode === 'separator') { + // Toggle background inversion + const newInvertBgs = [...invertBgs]; + newInvertBgs[selectedIndex] = !(newInvertBgs[selectedIndex] ?? false); + updateSeparators(separators, newInvertBgs); + } } } else { // Normal mode + const cmd = input.toLowerCase(); if (key.escape || key.leftArrow) { onBack(); } else if (key.upArrow && separators.length > 0) { @@ -227,7 +231,7 @@ export const PowerlineSeparatorEditor: React.FC = setSelectedIndex(selectedIndex >= separators.length - 1 ? 0 : selectedIndex + 1); } else if (key.return && separators.length > 0) { setFocusMode(true); - } else if ((input === 'a' || input === 'A') && (mode === 'separator' || separators.length < 3)) { + } else if (cmd === 'a' && (mode === 'separator' || separators.length < 3)) { // Add after current (max 3 for caps) const newSeparators = [...separators]; const newInvertBgs = mode === 'separator' ? [...invertBgs] : []; @@ -248,7 +252,7 @@ export const PowerlineSeparatorEditor: React.FC = updateSeparators(newSeparators, newInvertBgs); setSelectedIndex(selectedIndex + 1); } - } else if ((input === 'i' || input === 'I') && (mode === 'separator' || separators.length < 3)) { + } else if (cmd === 'i' && (mode === 'separator' || separators.length < 3)) { // Insert before current (max 3 for caps) const newSeparators = [...separators]; const newInvertBgs = mode === 'separator' ? [...invertBgs] : []; @@ -268,13 +272,13 @@ export const PowerlineSeparatorEditor: React.FC = } updateSeparators(newSeparators, newInvertBgs); } - } else if ((input === 'd' || input === 'D') && (mode !== 'separator' || separators.length > 1)) { + } else if (cmd === 'd' && (mode !== 'separator' || separators.length > 1)) { // Delete current (min 1 for separator, no min for caps) const newSeparators = separators.filter((_, i) => i !== selectedIndex); const newInvertBgs = mode === 'separator' ? invertBgs.filter((_, i) => i !== selectedIndex) : []; updateSeparators(newSeparators, newInvertBgs); setSelectedIndex(Math.min(selectedIndex, Math.max(0, newSeparators.length - 1))); - } else if (input === 'c' || input === 'C') { + } else if (cmd === 'c') { // Clear all if (mode === 'separator') { updateSeparators(['\uE0B0'], [false]); @@ -282,7 +286,7 @@ export const PowerlineSeparatorEditor: React.FC = updateSeparators([]); } setSelectedIndex(0); - } else if (input === 'h' || input === 'H') { + } else if (cmd === 'h') { // Enter hex input mode setHexInputMode(true); setHexInput(''); diff --git a/src/tui/components/PowerlineSetup.tsx b/src/tui/components/PowerlineSetup.tsx index 7b45cee8..3470ca39 100644 --- a/src/tui/components/PowerlineSetup.tsx +++ b/src/tui/components/PowerlineSetup.tsx @@ -183,9 +183,10 @@ export const PowerlineSetup: React.FC = ({ } if (screen === 'menu') { + const cmd = input.toLowerCase(); if (key.escape) { onBack(); - } else if (input === 't' || input === 'T') { + } else if (cmd === 't') { if (!powerlineConfig.enabled) { if (hasSeparatorItems) { setConfirmingEnable(true); @@ -201,9 +202,9 @@ export const PowerlineSetup: React.FC = ({ } }); } - } else if (input === 'i' || input === 'I') { + } else if (cmd === 'i') { setConfirmingFontInstall(true); - } else if ((input === 'a' || input === 'A') && powerlineConfig.enabled) { + } else if (cmd === 'a' && powerlineConfig.enabled) { onUpdate({ ...settings, powerline: { diff --git a/src/tui/components/PowerlineThemeSelector.tsx b/src/tui/components/PowerlineThemeSelector.tsx index 4489a432..7b8c8da5 100644 --- a/src/tui/components/PowerlineThemeSelector.tsx +++ b/src/tui/components/PowerlineThemeSelector.tsx @@ -139,10 +139,11 @@ export const PowerlineThemeSelector: React.FC = ({ return; } + const cmd = input.toLowerCase(); if (key.escape) { onUpdate(originalSettingsRef.current); onBack(); - } else if (input === 'c' || input === 'C') { + } else if (cmd === 'c') { const currentThemeName = themes[selectedIndex]; if (currentThemeName && currentThemeName !== 'custom') { setShowCustomizeConfirm(true); From 091c5fd24f439ccde2850ec08142dd46e2ab9290 Mon Sep 17 00:00:00 2001 From: Mark O'Keefe Date: Mon, 23 Mar 2026 23:28:02 +1100 Subject: [PATCH 42/42] fix: change "Enter to move" to "Enter to edit" in ItemsEditor help text Makes focus mode more discoverable by accurately describing that Enter enters edit mode (which supports type changes and more, not just moving). Move mode help text is unchanged. Co-Authored-By: Claude Sonnet 4.6 --- src/tui/components/ItemsEditor.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tui/components/ItemsEditor.tsx b/src/tui/components/ItemsEditor.tsx index 70ac833c..4ecca557 100644 --- a/src/tui/components/ItemsEditor.tsx +++ b/src/tui/components/ItemsEditor.tsx @@ -679,7 +679,7 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB // Rule property mode const expandedWidget = widgets.find(w => w.id === expandedWidgetId); - let ruleText = '↑↓ select, → edit condition, Enter move, (a)dd, (d)elete, (s)top, (h)ide, (c)lear properties, Tab: color mode, ← collapse, ESC: collapse'; + let ruleText = '↑↓ select, → edit condition, Enter to edit, (a)dd, (d)elete, (s)top, (h)ide, (c)lear properties, Tab: color mode, ← collapse, ESC: collapse'; if (expandedWidget && expandedWidget.type !== 'separator' && expandedWidget.type !== 'flex-separator') { const expandedWidgetImpl = getWidget(expandedWidget.type); @@ -728,7 +728,7 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB text += ', Space edit separator'; } if (hasWidgets) { - text += ', Enter to move, (a)dd via picker, (i)nsert via picker, (d)elete, (c)lear line'; + text += ', Enter to edit, (a)dd via picker, (i)nsert via picker, (d)elete, (c)lear line'; } if (canToggleRaw) { text += ', (r)aw value';