diff --git a/src/__tests__/shared/formatters.test.ts b/src/__tests__/shared/formatters.test.ts index c0236ebe..66aaee1b 100644 --- a/src/__tests__/shared/formatters.test.ts +++ b/src/__tests__/shared/formatters.test.ts @@ -15,6 +15,7 @@ import { formatCost, estimateTokenCount, truncatePath, + truncateCommand, } from '../../shared/formatters'; describe('shared/formatters', () => { @@ -363,4 +364,59 @@ describe('shared/formatters', () => { expect(truncatePath('/parent/child', 50)).toBe('/parent/child'); }); }); + + // ========================================================================== + // truncateCommand tests + // ========================================================================== + describe('truncateCommand', () => { + it('should return command unchanged if within maxLength', () => { + expect(truncateCommand('npm run build')).toBe('npm run build'); + expect(truncateCommand('git status', 20)).toBe('git status'); + }); + + it('should truncate long commands with ellipsis', () => { + const longCommand = 'npm run build --watch --verbose --output=/path/to/output'; + const result = truncateCommand(longCommand, 30); + expect(result.length).toBe(30); + expect(result.endsWith('…')).toBe(true); + }); + + it('should replace newlines with spaces', () => { + const multilineCommand = 'echo "hello\nworld"'; + const result = truncateCommand(multilineCommand, 50); + expect(result).toBe('echo "hello world"'); + expect(result.includes('\n')).toBe(false); + }); + + it('should trim whitespace', () => { + expect(truncateCommand(' git status ')).toBe('git status'); + expect(truncateCommand('\n\ngit status\n\n')).toBe('git status'); + }); + + it('should use default maxLength of 40', () => { + const longCommand = 'a'.repeat(50); + const result = truncateCommand(longCommand); + expect(result.length).toBe(40); + expect(result.endsWith('…')).toBe(true); + }); + + it('should respect custom maxLength parameter', () => { + const command = 'a'.repeat(100); + expect(truncateCommand(command, 20).length).toBe(20); + expect(truncateCommand(command, 50).length).toBe(50); + expect(truncateCommand(command, 60).length).toBe(60); + }); + + it('should handle multiple newlines as spaces', () => { + const command = 'echo "one\ntwo\nthree"'; + const result = truncateCommand(command, 50); + expect(result).toBe('echo "one two three"'); + }); + + it('should handle empty command', () => { + expect(truncateCommand('')).toBe(''); + expect(truncateCommand(' ')).toBe(''); + expect(truncateCommand('\n\n')).toBe(''); + }); + }); }); diff --git a/src/__tests__/web/mobile/CommandHistoryDrawer.test.tsx b/src/__tests__/web/mobile/CommandHistoryDrawer.test.tsx index bbfd4a84..5f53b7f5 100644 --- a/src/__tests__/web/mobile/CommandHistoryDrawer.test.tsx +++ b/src/__tests__/web/mobile/CommandHistoryDrawer.test.tsx @@ -208,8 +208,8 @@ describe('truncateCommand (via component)', () => { const entry = createMockEntry({ command: longCommand }); render(); - // Should truncate to 57 chars + '...' - const truncated = longCommand.slice(0, 57) + '...'; + // Should truncate to 59 chars + '…' (unicode ellipsis) = 60 total (uses shared truncateCommand) + const truncated = longCommand.slice(0, 59) + '…'; expect(screen.getByText(truncated)).toBeInTheDocument(); }); @@ -226,7 +226,8 @@ describe('truncateCommand (via component)', () => { const entry = createMockEntry({ command }); render(); - const truncated = 'a'.repeat(57) + '...'; + // Should truncate to 59 chars + '…' (unicode ellipsis) = 60 total (uses shared truncateCommand) + const truncated = 'a'.repeat(59) + '…'; expect(screen.getByText(truncated)).toBeInTheDocument(); }); }); @@ -924,11 +925,11 @@ describe('Edge cases', () => { const entry = createMockEntry({ command: ' ' }); render(); - // Should render whitespace command - getByText normalizes whitespace - // Instead, find the paragraph element with monospace font that contains whitespace + // Shared truncateCommand trims whitespace, so whitespace-only becomes empty string + // The entry should still render with an empty command text const commandElements = document.querySelectorAll('p[style*="font-family: ui-monospace"]'); - const whitespaceCommand = Array.from(commandElements).find(el => el.textContent === ' '); - expect(whitespaceCommand).toBeTruthy(); + const emptyCommand = Array.from(commandElements).find(el => el.textContent === ''); + expect(emptyCommand).toBeTruthy(); }); it('handles negative timestamp', () => { diff --git a/src/__tests__/web/mobile/OfflineQueueBanner.test.tsx b/src/__tests__/web/mobile/OfflineQueueBanner.test.tsx index 5a72fb52..11ef8b7f 100644 --- a/src/__tests__/web/mobile/OfflineQueueBanner.test.tsx +++ b/src/__tests__/web/mobile/OfflineQueueBanner.test.tsx @@ -113,9 +113,9 @@ describe('OfflineQueueBanner', () => { const toggleButton = screen.getByRole('button', { name: /command.* queued/i }); fireEvent.click(toggleButton); - // Should truncate to 40 chars total: substring(0, 37) + '...' - // longCommand.substring(0, 37) = "this is a very long command that shou" - expect(screen.getByText('this is a very long command that shou...')).toBeInTheDocument(); + // Should truncate to 40 chars total: slice(0, 39) + '…' (unicode ellipsis) + // Uses shared truncateCommand function + expect(screen.getByText(longCommand.slice(0, 39) + '…')).toBeInTheDocument(); }); it('handles command exactly at truncation boundary', () => { @@ -139,8 +139,8 @@ describe('OfflineQueueBanner', () => { const toggleButton = screen.getByRole('button', { name: /command.* queued/i }); fireEvent.click(toggleButton); - // Should truncate: 37 b's + '...' - expect(screen.getByText('b'.repeat(37) + '...')).toBeInTheDocument(); + // Should truncate: 39 b's + '…' (unicode ellipsis) = 40 total + expect(screen.getByText('b'.repeat(39) + '…')).toBeInTheDocument(); }); it('handles empty command', () => { diff --git a/src/shared/formatters.ts b/src/shared/formatters.ts index b54160c1..16f02207 100644 --- a/src/shared/formatters.ts +++ b/src/shared/formatters.ts @@ -14,6 +14,7 @@ * - formatCost: USD currency display ($1.23, <$0.01) * - estimateTokenCount: Estimate token count from text (~4 chars/token) * - truncatePath: Truncate file paths for display (...//) + * - truncateCommand: Truncate command text for display with ellipsis */ /** @@ -224,3 +225,18 @@ export function truncatePath(path: string, maxLength: number = 35): string { return `...${separator}${lastTwo}`; } + +/** + * Truncate command text for display. + * Replaces newlines with spaces, trims whitespace, and adds ellipsis if truncated. + * + * @param command - The command text to truncate + * @param maxLength - Maximum length of the returned string (default: 40) + * @returns Truncated command string (e.g., "npm run build --...") + */ +export function truncateCommand(command: string, maxLength: number = 40): string { + // Replace newlines with spaces for single-line display + const singleLine = command.replace(/\n/g, ' ').trim(); + if (singleLine.length <= maxLength) return singleLine; + return singleLine.slice(0, maxLength - 1) + '…'; +} diff --git a/src/web/mobile/CommandHistoryDrawer.tsx b/src/web/mobile/CommandHistoryDrawer.tsx index 67ced03d..4c0ccafc 100644 --- a/src/web/mobile/CommandHistoryDrawer.tsx +++ b/src/web/mobile/CommandHistoryDrawer.tsx @@ -18,7 +18,7 @@ import React, { useRef, useCallback, useState, useEffect } from 'react'; import { useThemeColors } from '../components/ThemeProvider'; import { triggerHaptic, HAPTIC_PATTERNS, GESTURE_THRESHOLDS } from './constants'; -import { formatRelativeTime } from '../../shared/formatters'; +import { formatRelativeTime, truncateCommand } from '../../shared/formatters'; import { useSwipeGestures } from '../hooks/useSwipeGestures'; import type { CommandHistoryEntry } from '../hooks/useCommandHistory'; @@ -37,6 +37,9 @@ const FLICK_VELOCITY_THRESHOLD = 0.5; /** Snap threshold - if dragged past this percentage, snap open/close */ const SNAP_THRESHOLD = 0.3; +/** Maximum length for truncated command display in drawer */ +const MAX_COMMAND_LENGTH = 60; + export interface CommandHistoryDrawerProps { /** Whether the drawer is open */ isOpen: boolean; @@ -52,16 +55,6 @@ export interface CommandHistoryDrawerProps { onClearHistory?: () => void; } -// formatRelativeTime imported from ../../shared/formatters - -/** - * Truncate command text for display - */ -function truncateCommand(command: string, maxLength = 60): string { - if (command.length <= maxLength) return command; - return command.slice(0, maxLength - 3) + '...'; -} - /** Width of the delete action button revealed on swipe */ const DELETE_ACTION_WIDTH = 80; @@ -276,7 +269,7 @@ function SwipeableHistoryItem({ textOverflow: 'ellipsis', }} > - {truncateCommand(entry.command)} + {truncateCommand(entry.command, MAX_COMMAND_LENGTH)}

)} {/* Command text */} - {truncateCommand(entry.command)} + {truncateCommand(entry.command, MAX_CHIP_LENGTH)} ))} diff --git a/src/web/mobile/SlashCommandAutocomplete.tsx b/src/web/mobile/SlashCommandAutocomplete.tsx index 8568e8f2..67177ef2 100644 --- a/src/web/mobile/SlashCommandAutocomplete.tsx +++ b/src/web/mobile/SlashCommandAutocomplete.tsx @@ -15,6 +15,7 @@ import React, { useEffect, useRef, useCallback } from 'react'; import { useThemeColors } from '../components/ThemeProvider'; import type { InputMode } from './CommandInputBar'; +import { MIN_TOUCH_TARGET } from './constants'; /** * Slash command definition @@ -51,9 +52,6 @@ export const DEFAULT_SLASH_COMMANDS: SlashCommand[] = [ }, ]; -/** Minimum touch target size per Apple HIG guidelines (44pt) */ -const MIN_TOUCH_TARGET = 44; - export interface SlashCommandAutocompleteProps { /** Whether the autocomplete is visible */ isOpen: boolean; diff --git a/src/web/mobile/constants.ts b/src/web/mobile/constants.ts index b80011f7..e56debdb 100644 --- a/src/web/mobile/constants.ts +++ b/src/web/mobile/constants.ts @@ -57,6 +57,12 @@ export const SAFE_AREA_DEFAULTS = { right: 0, } as const; +/** + * Minimum touch target size per Apple HIG guidelines (44pt). + * Use this constant for all interactive elements to ensure accessibility. + */ +export const MIN_TOUCH_TARGET = 44; + /** * Mobile gesture detection thresholds */