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