@@ -25,44 +25,66 @@ class PatternInference {
2525 }
2626
2727 /// Infer pattern for Bash commands
28- /// Example: "npm run test" → "Bash(npm run test: *)"
29- /// Example: "git status" → "Bash(git status: *)"
30- /// Example: "cd /path && dart pub get" → "Bash(dart pub get: *)"
31- /// Example: "find /path -name *.dart" → "Bash(find: *)"
32- /// Example: "dart test 2>&1" → "Bash(dart test: *)" (redirects stripped)
28+ /// Example: "npm run test" → "Bash(npm run test *)"
29+ /// Example: "git status" → "Bash(git status *)"
30+ /// Example: "cd /path && dart pub get" → "Bash(dart pub get *)"
31+ /// Example: "find /path -name *.dart" → "Bash(find *)"
32+ /// Example: "dart test 2>&1" → "Bash(dart test *)" (redirects stripped)
3333 static String _inferBashPattern (String command) {
3434 if (command.isEmpty) return 'Bash(*)' ;
3535
3636 // Parse compound commands
3737 final parsedCommands = BashCommandParser .parse (command);
3838
39- // Find the "main" command (skip cd commands)
40- var mainCommand = parsedCommands
41- .firstWhere (
42- (cmd) => cmd.type != CommandType .cd,
43- orElse: () => parsedCommands.isNotEmpty
44- ? parsedCommands.first
45- : ParsedCommand ('' , CommandType .simple),
46- )
47- .command;
39+ // Find the "main" command:
40+ // - Skip cd commands (just directory changes)
41+ // - Skip prefix commands (sleep, timeout, env, etc.)
42+ // - Skip pipeline filter parts (grep, head, tail after |)
43+ // - Prefer the last meaningful command (prefix cmds appear first in chains)
44+ var mainCommand = '' ;
45+ for (final cmd in parsedCommands.reversed) {
46+ if (cmd.type == CommandType .cd) continue ;
47+ if (cmd.type == CommandType .pipelinePart) continue ;
48+ if (_isPrefixCommand (cmd.command)) continue ;
49+ mainCommand = cmd.command;
50+ break ;
51+ }
52+ // Fallback: if all non-cd commands are pipeline parts, use the first one
53+ // (the producer, not the filter)
54+ if (mainCommand.isEmpty) {
55+ mainCommand = parsedCommands
56+ .firstWhere (
57+ (cmd) => cmd.type != CommandType .cd && ! _isPrefixCommand (cmd.command),
58+ orElse: () => parsedCommands.firstWhere (
59+ (cmd) => cmd.type != CommandType .cd,
60+ orElse: () => parsedCommands.isNotEmpty
61+ ? parsedCommands.first
62+ : ParsedCommand ('' , CommandType .simple),
63+ ),
64+ )
65+ .command;
66+ }
4867
4968 if (mainCommand.isEmpty) return 'Bash(*)' ;
5069
5170 // Strip shell redirects from the command before inferring pattern
5271 // These are implementation details that shouldn't be part of the pattern
5372 mainCommand = _stripShellRedirects (mainCommand);
5473
55- // Split into parts
56- final parts = mainCommand.trim (). split ( RegExp ( r'\s+' ));
74+ // Split into parts (quote-aware)
75+ final parts = _splitRespectingQuotes ( mainCommand.trim ());
5776 if (parts.isEmpty) return 'Bash(*)' ;
5877
59- // Extract base command (command name only, no path arguments)
78+ // Extract base command (command name only, no path/flag/quoted arguments)
6079 final baseParts = < String > [];
6180
6281 for (final part in parts) {
6382 // Stop at flags
6483 if (part.startsWith ('-' )) break ;
6584
85+ // Stop at quoted arguments (these are values, not sub-command names)
86+ if (part.startsWith ('"' ) || part.startsWith ("'" )) break ;
87+
6688 // Stop at path-like arguments (starting with / or ./ or ~/ or ..)
6789 if (part.startsWith ('/' ) ||
6890 part.startsWith ('./' ) ||
@@ -79,7 +101,62 @@ class PatternInference {
79101 }
80102
81103 final baseCommand = baseParts.join (' ' );
82- return baseCommand.isEmpty ? 'Bash(*)' : 'Bash($baseCommand :*)' ;
104+ return baseCommand.isEmpty ? 'Bash(*)' : 'Bash($baseCommand *)' ;
105+ }
106+
107+ /// Split a command string into parts, keeping quoted strings as single tokens.
108+ static List <String > _splitRespectingQuotes (String command) {
109+ final parts = < String > [];
110+ final buffer = StringBuffer ();
111+ var inSingleQuote = false ;
112+ var inDoubleQuote = false ;
113+
114+ for (var i = 0 ; i < command.length; i++ ) {
115+ final char = command[i];
116+
117+ if (char == "'" && ! inDoubleQuote) {
118+ inSingleQuote = ! inSingleQuote;
119+ buffer.write (char);
120+ } else if (char == '"' && ! inSingleQuote) {
121+ inDoubleQuote = ! inDoubleQuote;
122+ buffer.write (char);
123+ } else if (char == ' ' && ! inSingleQuote && ! inDoubleQuote) {
124+ if (buffer.isNotEmpty) {
125+ parts.add (buffer.toString ());
126+ buffer.clear ();
127+ }
128+ } else {
129+ buffer.write (char);
130+ }
131+ }
132+
133+ if (buffer.isNotEmpty) {
134+ parts.add (buffer.toString ());
135+ }
136+
137+ return parts;
138+ }
139+
140+ /// Commands that are typically used as prefixes before the "real" command.
141+ /// These are delay/wrapper commands that shouldn't be used for pattern inference.
142+ static const _prefixCommands = {
143+ 'sleep' ,
144+ 'timeout' ,
145+ 'env' ,
146+ 'nice' ,
147+ 'nohup' ,
148+ 'time' ,
149+ 'watch' ,
150+ 'retry' ,
151+ 'wait' ,
152+ 'true' ,
153+ 'false' ,
154+ };
155+
156+ /// Check if a command is a prefix/wrapper command like sleep, timeout, etc.
157+ static bool _isPrefixCommand (String command) {
158+ final firstWord = command.trim ().split (RegExp (r'\s+' )).firstOrNull ?? '' ;
159+ return _prefixCommands.contains (firstWord);
83160 }
84161
85162 /// Strip shell redirects from a command string.
0 commit comments