From 19db794978e18721cbb67f10243e1bf332de16ec Mon Sep 17 00:00:00 2001 From: Mark O'Keefe Date: Mon, 23 Mar 2026 14:45:37 +1100 Subject: [PATCH 1/5] 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 2/5] 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 3/5] 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 4/5] 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 5/5] 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 }