MAESTRO: Task 167 - Deduplicate truncateCommand and MIN_TOUCH_TARGET in mobile components

- Add shared truncateCommand(command, maxLength) to src/shared/formatters.ts
  - Handles newline replacement, whitespace trimming, unicode ellipsis
  - 8 comprehensive tests added
- Add MIN_TOUCH_TARGET constant to src/web/mobile/constants.ts
- Refactor OfflineQueueBanner, RecentCommandChips, CommandHistoryDrawer to use shared truncateCommand
- Refactor QuickActionsMenu, CommandInputButtons, SlashCommandAutocomplete to use shared MIN_TOUCH_TARGET
- Update test expectations for new unicode ellipsis format
This commit is contained in:
Pedram Amini
2025-12-22 20:58:11 -06:00
parent a97c5d0d78
commit 88ca2edd56
11 changed files with 102 additions and 56 deletions

View File

@@ -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('');
});
});
});

View File

@@ -208,8 +208,8 @@ describe('truncateCommand (via component)', () => {
const entry = createMockEntry({ command: longCommand });
render(<CommandHistoryDrawer {...createDefaultProps({ history: [entry] })} />);
// 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(<CommandHistoryDrawer {...createDefaultProps({ history: [entry] })} />);
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(<CommandHistoryDrawer {...createDefaultProps({ history: [entry] })} />);
// 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', () => {

View File

@@ -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', () => {

View File

@@ -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 (.../<parent>/<current>)
* - 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) + '…';
}

View File

@@ -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)}
</p>
<p
style={{

View File

@@ -17,10 +17,7 @@
import React from 'react';
import { useThemeColors } from '../components/ThemeProvider';
import type { InputMode } from './CommandInputBar';
import { triggerHaptic } from './constants';
/** Minimum touch target size per Apple HIG guidelines (44pt) */
const MIN_TOUCH_TARGET = 44;
import { triggerHaptic, MIN_TOUCH_TARGET } from './constants';
/** Default minimum height for the buttons */
const MIN_INPUT_HEIGHT = 48;

View File

@@ -17,7 +17,7 @@ import { useThemeColors } from '../components/ThemeProvider';
import { Badge } from '../components/Badge';
import type { QueuedCommand, QueueStatus } from '../hooks/useOfflineQueue';
import { triggerHaptic, HAPTIC_PATTERNS } from './constants';
import { formatRelativeTime } from '../../shared/formatters';
import { formatRelativeTime, truncateCommand } from '../../shared/formatters';
export interface OfflineQueueBannerProps {
/** Queued commands */
@@ -36,16 +36,6 @@ export interface OfflineQueueBannerProps {
isConnected: boolean;
}
/**
* Truncate command text for display
*/
function truncateCommand(command: string, maxLength: number = 40): string {
if (command.length <= maxLength) return command;
return command.substring(0, maxLength - 3) + '...';
}
// formatRelativeTime imported from ../../shared/formatters
export function OfflineQueueBanner({
queue,
status,

View File

@@ -15,9 +15,7 @@
import React, { useEffect, useRef } from 'react';
import { useThemeColors } from '../components/ThemeProvider';
/** Minimum touch target size per Apple HIG guidelines (44pt) */
const MIN_TOUCH_TARGET = 44;
import { MIN_TOUCH_TARGET } from './constants';
export type QuickAction = 'switch_mode';

View File

@@ -17,6 +17,7 @@ import React, { useCallback, useRef, useEffect } from 'react';
import { useThemeColors } from '../components/ThemeProvider';
import { triggerHaptic, HAPTIC_PATTERNS } from './constants';
import type { CommandHistoryEntry } from '../hooks/useCommandHistory';
import { truncateCommand } from '../../shared/formatters';
/** Maximum characters to show in a chip before truncating */
const MAX_CHIP_LENGTH = 30;
@@ -35,16 +36,6 @@ export interface RecentCommandChipsProps {
disabled?: boolean;
}
/**
* Truncate command text for display in chip
*/
function truncateCommand(command: string, maxLength: number = MAX_CHIP_LENGTH): string {
// Replace newlines with spaces for display
const singleLine = command.replace(/\n/g, ' ').trim();
if (singleLine.length <= maxLength) return singleLine;
return singleLine.slice(0, maxLength - 1) + '…';
}
/**
* RecentCommandChips component
*
@@ -204,7 +195,7 @@ export function RecentCommandChips({
</svg>
)}
{/* Command text */}
<span>{truncateCommand(entry.command)}</span>
<span>{truncateCommand(entry.command, MAX_CHIP_LENGTH)}</span>
</button>
))}
</div>

View File

@@ -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;

View File

@@ -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
*/